Skip to content

Commit f1246c0

Browse files
feat: implement offline mode with snapshot support for network access
1 parent cfb1409 commit f1246c0

13 files changed

+287
-35
lines changed

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,18 @@ the context of a real repository:
147147
Each template ships with a README snippet and status badge you can adapt when
148148
bootstrapping your own public showcase repository.
149149

150+
### Offline mode
151+
152+
Set `NO_NETWORK_FALLBACK=true` and supply snapshots so the action can run without hitting
153+
external endpoints:
154+
155+
- `CPYTHON_TAGS_SNAPSHOT` – JSON array of CPython tag objects.
156+
- `PYTHON_ORG_HTML_SNAPSHOT` – Raw HTML or path to a saved python.org releases page.
157+
- `RUNNER_MANIFEST_SNAPSHOT` – JSON manifest compatible with `actions/python-versions`.
158+
159+
Each variable accepts either the data directly or a path to a file containing the snapshot. When
160+
offline mode is enabled and a snapshot is missing, the run will fail fast with a clear message.
161+
150162
---
151163

152164
## Permissions

SECURITY.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@ External network access is limited to:
3030
- `www.python.org` – fallback source to confirm released patch versions.
3131

3232
No telemetry or analytics endpoints are used. If you need to run the action in a
33-
restricted environment, consider mirroring the above endpoints and pointing the
34-
workflow to your mirrors.
33+
restricted environment, you can enable offline mode by setting `NO_NETWORK_FALLBACK=true`
34+
and providing snapshot data via `CPYTHON_TAGS_SNAPSHOT`, `PYTHON_ORG_HTML_SNAPSHOT`, and
35+
`RUNNER_MANIFEST_SNAPSHOT`. Each variable accepts either inline JSON/HTML or a path to a
36+
local file containing the snapshot data.
3537

3638
## Handling secrets
3739

docs/tasks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ Use Context7 MCP for up to date documentation.
241241
PR body includes exact git commands.
242242
Verify: Snapshot contains commands with placeholders.
243243

244-
40. [ ] **No extra telemetry**
244+
40. [x] **No extra telemetry**
245245
Only GitHub + python.org calls. Env `NO_NETWORK_FALLBACK=true` supported with injected data.
246246
Verify: Network-blocked tests pass using fixtures.
247247

src/action-execution.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from './versioning';
1414
import { createOrUpdatePullRequest, findExistingPullRequest, type PullRequestResult } from './git';
1515
import { generatePullRequestBody } from './pr-body';
16+
import type { StableTag } from './github';
1617

1718
export type SkipReason =
1819
| 'no_matches_found'
@@ -34,6 +35,12 @@ export interface ExecuteOptions {
3435
repository?: { owner: string; repo: string } | null;
3536
defaultBranch?: string;
3637
allowPrCreation?: boolean;
38+
noNetworkFallback?: boolean;
39+
snapshots?: {
40+
cpythonTags?: StableTag[];
41+
pythonOrgHtml?: string;
42+
runnerManifest?: unknown;
43+
};
3744
}
3845

3946
export interface ExecuteDependencies {
@@ -114,6 +121,8 @@ export async function executeAction(
114121
repository,
115122
defaultBranch = 'main',
116123
allowPrCreation = false,
124+
noNetworkFallback = false,
125+
snapshots,
117126
} = options;
118127

119128
const scanResult = await dependencies.scanForPythonVersions({
@@ -143,8 +152,14 @@ export async function executeAction(
143152
const latestPatch = await dependencies.resolveLatestPatch(track, {
144153
includePrerelease,
145154
token: githubToken,
155+
tags: snapshots?.cpythonTags,
156+
noNetworkFallback,
157+
});
158+
const fallback = await dependencies.fetchLatestFromPythonOrg({
159+
track,
160+
htmlSnapshot: snapshots?.pythonOrgHtml,
161+
noNetworkFallback,
146162
});
147-
const fallback = await dependencies.fetchLatestFromPythonOrg({ track });
148163
const latestVersion = selectLatestVersion(track, latestPatch, fallback);
149164

150165
const guard = dependencies.enforcePreReleaseGuard(includePrerelease, latestVersion);
@@ -156,7 +171,10 @@ export async function executeAction(
156171
} satisfies SkipResult;
157172
}
158173

159-
const availability = await dependencies.fetchRunnerAvailability(latestVersion);
174+
const availability = await dependencies.fetchRunnerAvailability(latestVersion, {
175+
manifestSnapshot: snapshots?.runnerManifest,
176+
noNetworkFallback,
177+
});
160178
const missingRunners = determineMissingRunners(availability);
161179
if (missingRunners.length > 0) {
162180
return {

src/index.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { existsSync, readFileSync } from 'node:fs';
12
import process from 'node:process';
23

34
import * as core from '@actions/core';
@@ -17,6 +18,7 @@ import {
1718
} from './versioning';
1819
import { createOrUpdatePullRequest, findExistingPullRequest } from './git';
1920
import { validateTrack } from './config';
21+
import type { StableTag } from './github';
2022

2123
const DEFAULT_TRACK = '3.13';
2224
const DEFAULT_PATHS = [
@@ -51,6 +53,48 @@ function resolvePathsInput(): string[] {
5153
return explicitPaths.length > 0 ? explicitPaths : DEFAULT_PATHS;
5254
}
5355

56+
function loadJsonSnapshot(envName: string): unknown | undefined {
57+
const raw = process.env[envName];
58+
if (!raw || raw.trim() === '') {
59+
return undefined;
60+
}
61+
62+
const trimmed = raw.trim();
63+
64+
try {
65+
return JSON.parse(trimmed);
66+
} catch (primaryError) {
67+
if (existsSync(trimmed)) {
68+
try {
69+
const fileContent = readFileSync(trimmed, 'utf8');
70+
return JSON.parse(fileContent);
71+
} catch (fileError) {
72+
throw new Error(
73+
`Failed to parse JSON snapshot from ${envName}. Ensure it contains valid JSON or a path to a JSON file. Original error: ${(fileError as Error).message}`,
74+
);
75+
}
76+
}
77+
78+
throw new Error(
79+
`Failed to parse JSON snapshot from ${envName}. Provide valid JSON or a path to a JSON file. Original error: ${(primaryError as Error).message}`,
80+
);
81+
}
82+
}
83+
84+
function loadTextSnapshot(envName: string): string | undefined {
85+
const raw = process.env[envName];
86+
if (!raw || raw.trim() === '') {
87+
return undefined;
88+
}
89+
90+
const trimmed = raw.trim();
91+
if (existsSync(trimmed)) {
92+
return readFileSync(trimmed, 'utf8');
93+
}
94+
95+
return raw;
96+
}
97+
5498
function parseRepository(slug: string | undefined): { owner: string; repo: string } | null {
5599
if (!slug) {
56100
return null;
@@ -165,6 +209,31 @@ export async function run(): Promise<void> {
165209
const repository = parseRepository(process.env.GITHUB_REPOSITORY);
166210
const githubToken = process.env.GITHUB_TOKEN;
167211
const defaultBranch = process.env.GITHUB_BASE_REF ?? process.env.GITHUB_REF_NAME ?? 'main';
212+
const noNetworkFallback = (process.env.NO_NETWORK_FALLBACK ?? '').toLowerCase() === 'true';
213+
214+
let cpythonTagsSnapshot: StableTag[] | undefined;
215+
let pythonOrgHtmlSnapshot: string | undefined;
216+
let runnerManifestSnapshot: unknown | undefined;
217+
218+
try {
219+
const rawTagsSnapshot = loadJsonSnapshot('CPYTHON_TAGS_SNAPSHOT');
220+
if (rawTagsSnapshot !== undefined) {
221+
if (!Array.isArray(rawTagsSnapshot)) {
222+
throw new Error('CPYTHON_TAGS_SNAPSHOT must be a JSON array.');
223+
}
224+
cpythonTagsSnapshot = rawTagsSnapshot as StableTag[];
225+
}
226+
227+
pythonOrgHtmlSnapshot = loadTextSnapshot('PYTHON_ORG_HTML_SNAPSHOT');
228+
runnerManifestSnapshot = loadJsonSnapshot('RUNNER_MANIFEST_SNAPSHOT');
229+
} catch (snapshotError) {
230+
if (snapshotError instanceof Error) {
231+
core.setFailed(snapshotError.message);
232+
} else {
233+
core.setFailed('Failed to load offline snapshots.');
234+
}
235+
return;
236+
}
168237

169238
core.startGroup('Configuration');
170239
core.info(`workspace: ${workspace}`);
@@ -173,6 +242,7 @@ export async function run(): Promise<void> {
173242
core.info(`paths (${effectivePaths.length}): ${effectivePaths.join(', ')}`);
174243
core.info(`automerge: ${automerge}`);
175244
core.info(`dry_run: ${dryRun}`);
245+
core.info(`no_network_fallback: ${noNetworkFallback}`);
176246
if (repository) {
177247
core.info(`repository: ${repository.owner}/${repository.repo}`);
178248
}
@@ -196,6 +266,12 @@ export async function run(): Promise<void> {
196266
repository,
197267
defaultBranch,
198268
allowPrCreation: false,
269+
noNetworkFallback,
270+
snapshots: {
271+
cpythonTags: cpythonTagsSnapshot,
272+
pythonOrgHtml: pythonOrgHtmlSnapshot,
273+
runnerManifest: runnerManifestSnapshot,
274+
},
199275
},
200276
dependencies,
201277
);

src/versioning/latest-patch-resolver.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export interface ResolveLatestPatchOptions extends FetchStableTagsOptions {
88
includePrerelease?: boolean;
99
/// Optional override for the tag list, used primarily for testing.
1010
tags?: StableTag[];
11+
/// When true, skip network calls and require tags override.
12+
noNetworkFallback?: boolean;
1113
}
1214

1315
export interface LatestPatchResult {
@@ -43,7 +45,13 @@ export async function resolveLatestPatch(
4345
throw new Error(`Track "${track}" must be in the form X.Y`);
4446
}
4547

46-
const { includePrerelease = false, tags, ...fetchOptions } = options;
48+
const { includePrerelease = false, tags, noNetworkFallback = false, ...fetchOptions } = options;
49+
50+
if (!tags && noNetworkFallback) {
51+
throw new Error(
52+
'Network access disabled via NO_NETWORK_FALLBACK. Provide tags override to resolve latest patch.',
53+
);
54+
}
4755

4856
const stableTags = tags ?? (await fetchStableCpythonTags(fetchOptions));
4957
const candidates = filterByTrack(stableTags, normalizedTrack, includePrerelease);

src/versioning/python-org-fallback.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
99
export interface PythonOrgFallbackOptions {
1010
fetchImpl?: FetchLike;
1111
track: string;
12+
noNetworkFallback?: boolean;
13+
htmlSnapshot?: string;
1214
}
1315

1416
export interface PythonOrgVersion {
@@ -36,22 +38,35 @@ function extractVersions(html: string): string[] {
3638
export async function fetchLatestFromPythonOrg(
3739
options: PythonOrgFallbackOptions,
3840
): Promise<PythonOrgVersion | null> {
39-
const { track, fetchImpl = defaultFetch } = options;
41+
const { track, fetchImpl = defaultFetch, noNetworkFallback = false, htmlSnapshot } = options;
4042
const normalizedTrack = track.trim();
4143
if (!/^\d+\.\d+$/.test(normalizedTrack)) {
4244
throw new Error(`Track "${track}" must be in the form X.Y`);
4345
}
4446

45-
const response = await fetchImpl(PYTHON_RELEASES_URL, {
46-
method: 'GET',
47-
} satisfies RequestInit);
47+
let htmlContent: string;
4848

49-
if (!response.ok) {
50-
throw new Error(`Failed to fetch python.org releases (status ${response.status}).`);
49+
if (htmlSnapshot) {
50+
htmlContent = htmlSnapshot;
51+
} else {
52+
if (noNetworkFallback) {
53+
throw new Error(
54+
'Network access disabled via NO_NETWORK_FALLBACK. Provide htmlSnapshot to fetchLatestFromPythonOrg.',
55+
);
56+
}
57+
58+
const response = await fetchImpl(PYTHON_RELEASES_URL, {
59+
method: 'GET',
60+
} satisfies RequestInit);
61+
62+
if (!response.ok) {
63+
throw new Error(`Failed to fetch python.org releases (status ${response.status}).`);
64+
}
65+
66+
htmlContent = await response.text();
5167
}
5268

53-
const html = await response.text();
54-
const versions = extractVersions(html);
69+
const versions = extractVersions(htmlContent);
5570

5671
const candidates = versions.filter((version) => version.startsWith(`${normalizedTrack}.`));
5772
if (candidates.length === 0) {

src/versioning/runner-availability.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
99
export interface RunnerAvailabilityOptions {
1010
fetchImpl?: FetchLike;
1111
signal?: AbortSignal;
12+
noNetworkFallback?: boolean;
13+
manifestSnapshot?: unknown;
1214
}
1315

1416
export interface RunnerAvailability {
@@ -69,20 +71,33 @@ export async function fetchRunnerAvailability(
6971
version: string,
7072
options: RunnerAvailabilityOptions = {},
7173
): Promise<RunnerAvailability | null> {
72-
const { fetchImpl = defaultFetch, signal } = options;
74+
const { fetchImpl = defaultFetch, signal, noNetworkFallback = false, manifestSnapshot } = options;
7375

74-
const response = await fetchImpl(MANIFEST_URL, {
75-
method: 'GET',
76-
signal,
77-
} satisfies RequestInit);
76+
let payload: unknown;
7877

79-
if (!response.ok) {
80-
throw new Error(
81-
`Failed to fetch versions manifest from actions/python-versions (status ${response.status}).`,
82-
);
78+
if (manifestSnapshot !== undefined) {
79+
payload = manifestSnapshot;
80+
} else {
81+
if (noNetworkFallback) {
82+
throw new Error(
83+
'Network access disabled via NO_NETWORK_FALLBACK. Provide manifestSnapshot to fetchRunnerAvailability.',
84+
);
85+
}
86+
87+
const response = await fetchImpl(MANIFEST_URL, {
88+
method: 'GET',
89+
signal,
90+
} satisfies RequestInit);
91+
92+
if (!response.ok) {
93+
throw new Error(
94+
`Failed to fetch versions manifest from actions/python-versions (status ${response.status}).`,
95+
);
96+
}
97+
98+
payload = await response.json();
8399
}
84100

85-
const payload = await response.json();
86101
const manifestResult = manifestSchema.safeParse(payload);
87102

88103
if (!manifestResult.success) {

tests/action-execution.test.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, expect, it, beforeEach, vi } from 'vitest';
2-
3-
import { executeAction, type ExecuteDependencies } from '../src/action-execution';
2+
import {
3+
executeAction,
4+
type ExecuteDependencies,
5+
type ExecuteOptions,
6+
} from '../src/action-execution';
7+
import type { PullRequestResult } from '../src/git';
48
import type { VersionMatch } from '../src/scanning';
59
import type { ScanResult } from '../src/scanning/scanner';
610

@@ -38,14 +42,16 @@ const baseDependencies = (): ExecuteDependencies => ({
3842
availableOn: { linux: true, mac: true, win: true },
3943
})),
4044
findExistingPullRequest: vi.fn(),
41-
createOrUpdatePullRequest: vi.fn(async () => ({
42-
action: 'created',
43-
number: 1,
44-
url: undefined,
45-
})),
45+
createOrUpdatePullRequest: vi.fn(
46+
async (): Promise<PullRequestResult> => ({
47+
action: 'created',
48+
number: 1,
49+
url: undefined,
50+
}),
51+
),
4652
});
4753

48-
const baseOptions = {
54+
const baseOptions: ExecuteOptions = {
4955
workspace: '.',
5056
track: '3.13',
5157
includePrerelease: false,
@@ -56,7 +62,9 @@ const baseOptions = {
5662
repository: null,
5763
defaultBranch: 'main',
5864
allowPrCreation: false,
59-
} as const;
65+
noNetworkFallback: false,
66+
snapshots: undefined,
67+
};
6068

6169
describe('executeAction failure modes', () => {
6270
let deps: ExecuteDependencies;
@@ -145,7 +153,7 @@ describe('executeAction failure modes', () => {
145153

146154
it('returns pr_creation_failed when PR creation throws', async () => {
147155
deps.findExistingPullRequest = vi.fn(async () => null);
148-
deps.createOrUpdatePullRequest = vi.fn(async () => {
156+
deps.createOrUpdatePullRequest = vi.fn(async (): Promise<PullRequestResult> => {
149157
throw new Error('boom');
150158
});
151159

0 commit comments

Comments
 (0)