From d8ad946bbd52977bcb357acfb8bb001f872044af Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Wed, 24 Jun 2026 11:51:44 -0700 Subject: [PATCH 1/4] Hard-purge changed English content URLs on prod deploy (#61886) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/purge-fastly.yml | 15 + package.json | 1 + src/workflows/purge-fastly-changed-content.ts | 291 ++++++++++++++++++ .../tests/purge-fastly-changed-content.ts | 180 +++++++++++ 4 files changed, 487 insertions(+) create mode 100644 src/workflows/purge-fastly-changed-content.ts create mode 100644 src/workflows/tests/purge-fastly-changed-content.ts diff --git a/.github/workflows/purge-fastly.yml b/.github/workflows/purge-fastly.yml index 44340c93662d..660a26098fb9 100644 --- a/.github/workflows/purge-fastly.yml +++ b/.github/workflows/purge-fastly.yml @@ -27,6 +27,7 @@ on: permissions: contents: read + deployments: read # Serialize full-cache purges so two can't overlap and leave the cache in an # unknown state. Every other run (per-deploy, per-language) gets a unique group @@ -104,6 +105,20 @@ jobs: fi npm run purge-fastly -- "${args[@]}" + - name: Hard-purge changed English content URLs + # Prod deploys only. The soft purge above just marks `language:en` stale, + # so stale-while-revalidate can keep serving the pre-deploy copy of a + # just-changed page for a while. This evicts the specific English URLs + # whose content/ files changed in this deploy, so the next request fetches + # fresh. By the time the deploy succeeds the old pods are already gone, so + # the refill is deterministically the new content. data/ changes stay + # covered by the soft purge above (too many URLs to enumerate cheaply). + if: ${{ github.event_name == 'deployment_status' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_SHA: ${{ github.event.deployment.sha }} + run: npm run purge-fastly-changed-content + - uses: ./.github/actions/slack-alert if: ${{ failure() && github.event_name != 'workflow_dispatch' }} with: diff --git a/package.json b/package.json index bf8af8ddaa3b..30ba64d17eeb 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "prettier-check": "prettier -c \"**/*.{ts,tsx,scss,yml,yaml}\"", "prevent-pushes-to-main": "tsx src/workflows/prevent-pushes-to-main.ts", "purge-fastly": "tsx src/workflows/purge-fastly.ts", + "purge-fastly-changed-content": "tsx src/workflows/purge-fastly-changed-content.ts", "readability-report": "tsx src/workflows/experimental/readability-report.ts", "ready-for-docs-review": "tsx src/workflows/ready-for-docs-review.ts", "release-banner": "tsx src/ghes-releases/scripts/release-banner.ts", diff --git a/src/workflows/purge-fastly-changed-content.ts b/src/workflows/purge-fastly-changed-content.ts new file mode 100644 index 000000000000..3c1e6e2509e9 --- /dev/null +++ b/src/workflows/purge-fastly-changed-content.ts @@ -0,0 +1,291 @@ +import type { Octokit } from '@octokit/rest' + +import { fetchWithRetry } from '@/frame/lib/fetch-utils' +import warmServer from '@/frame/lib/warm-server' +import github from './github' +import { getActionContext } from './action-context' + +// Hard-purges the specific English URLs whose content changed in a production +// deploy. It runs *in addition to* the routine soft purge of the whole +// `language:en` surrogate key (see purge-fastly.ts / purge-fastly.yml). +// +// Why: the soft purge only marks `language:en` stale. With stale-while- +// revalidate, Fastly keeps serving the pre-deploy copy of a just-changed page +// until a background revalidation completes, so an author who reloads the page +// they just edited can still see the old content for a while. (The classic +// "add ?bla=1234 and it shows the new content" symptom: the query string is a +// cache miss that fetches fresh, proving origin is fresh and the cached object +// is stale.) A hard purge *evicts* the changed URLs so the next request fetches +// fresh. By the time deployment succeeds, the old pods are already terminated +// (Heaven waits for the rollout to fully reconcile), so this is deterministic. +// +// Scope: content/ only. data/ changes (reusables, variables, ...) fan out to +// far too many URLs to enumerate cheaply, so they stay covered by the soft +// purge of the whole language. + +const PROD_HOST = 'docs.github.com' +const CONTENT_PREFIX = 'content/' + +// A defensive ceiling. A normal content deploy touches a handful of files -> +// tens of URLs. If a deploy changes so much that we'd hard-purge more than this, +// skip the targeted purge: the soft purge of the whole `language:en` key (which +// always runs) already covers it, and we don't want to hammer Fastly. +const MAX_URLS = 1000 + +// The GitHub compare API returns at most 300 files per page. If we hit that, the +// deploy is large enough that the soft-purge-all is the right tool; bail rather +// than paginate through a huge change set. +const COMPARE_FILE_LIMIT = 300 + +// How many purge requests to keep in flight at once. +const PURGE_CONCURRENCY = 10 + +type ChangedFile = { + filename: string + status: string +} + +// The most recent production deployment that was actually live before `headSha`. +// We diff against this to find what changed in the current deploy. The merge +// queue can batch several PRs into one deploy, so this range can span multiple +// merge commits — that's intentional, we want every changed file in the batch. +export async function resolvePreviousProductionSha( + octokit: Octokit, + owner: string, + repo: string, + headSha: string, +): Promise { + const { data: deployments } = await octokit.rest.repos.listDeployments({ + owner, + repo, + environment: 'production', + per_page: 30, + }) + + for (const deployment of deployments) { + if (deployment.sha === headSha) continue + const { data: statuses } = await octokit.rest.repos.listDeploymentStatuses({ + owner, + repo, + deployment_id: deployment.id, + per_page: 30, + }) + // Require evidence the sha was actually live: a `success` status. The + // previous live deploy keeps its `success` status in history even after a + // newer deploy marks it `inactive`, so this still finds it. We deliberately + // do NOT accept `inactive` alone, since a deploy that failed and was later + // auto-inactivated never served traffic and would give a wrong base. + if (statuses.some((status) => status.state === 'success')) { + return deployment.sha + } + } + return null +} + +// The content/*.md files that changed between two commits. Removed files are +// excluded: there's no page left to derive permalinks from. Their old URL is +// covered by the soft purge of the whole language plus the redirect that +// replaces a removed/renamed page, so it isn't enumerated here. +// +// Note: compareCommitsWithBasehead uses three-dot (merge-base) semantics. For +// normal forward-moving deploys that equals the tree diff. For a rollback (head +// is an ancestor of, or diverged from, the previous live sha) it can miss the +// reverted files; those simply fall back to the existing soft-purge-of-all +// behavior, so it's not a regression. Returns null if the change set is too +// large to handle as a targeted purge. +export async function getChangedContentFiles( + octokit: Octokit, + owner: string, + repo: string, + baseSha: string, + headSha: string, +): Promise { + const { data } = await octokit.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${baseSha}...${headSha}`, + }) + + const files = data.files || [] + if (files.length >= COMPARE_FILE_LIMIT) { + return null + } + + return files + .filter( + (file) => + file.filename.startsWith(CONTENT_PREFIX) && + file.filename.toLowerCase().endsWith('.md') && + file.status !== 'removed', + ) + .map((file) => ({ filename: file.filename, status: file.status })) +} + +// Map changed content files to the full English production URLs Fastly caches. +// `permalinksFor` looks up a page's English permalink paths by its relativePath +// (the path under content/). A page yields one URL per applicable version +// (e.g. fpt, ghec, each ghes release). Unknown files (e.g. a page that doesn't +// resolve to permalinks) are skipped. +export function contentFilesToEnglishUrls( + changedFiles: ChangedFile[], + permalinksFor: (relativePath: string) => string[], +): string[] { + const urls = new Set() + for (const file of changedFiles) { + const relativePath = file.filename.slice(CONTENT_PREFIX.length) + for (const href of permalinksFor(relativePath)) { + urls.add(`https://${PROD_HOST}${href}`) + } + } + return [...urls] +} + +// Build relativePath -> English permalink hrefs from the warmed page list. +async function loadEnglishPermalinkIndex(): Promise> { + const { pageList } = await warmServer(['en']) + const index = new Map() + for (const page of pageList) { + if (page.languageCode !== 'en') continue + const hrefs = page.permalinks + .filter((permalink) => permalink.languageCode === 'en') + .map((permalink) => permalink.href) + if (hrefs.length) { + index.set(page.relativePath, hrefs) + } + } + return index +} + +// Single-URL hard purge. Fastly's URL purge API is host+path scoped (not service +// scoped) and uses only the Fastly-Key. Omitting the soft-purge header makes it +// a hard purge: the object is evicted, so the next request is a fresh miss. +// https://www.fastly.com/documentation/reference/api/purging/#purge-a-url +async function hardPurgeUrl(url: string, fastlyToken: string): Promise { + const withoutScheme = url.replace(/^https?:\/\//, '') + const response = await fetchWithRetry( + `https://api.fastly.com/purge/${withoutScheme}`, + { + method: 'POST', + headers: { + 'fastly-key': fastlyToken, + accept: 'application/json', + }, + }, + { retries: 3, timeout: 30_000, throwHttpErrors: false }, + ) + if (!response.ok) { + let body = '' + try { + body = await response.text() + } catch { + body = '' + } + throw new Error( + `Fastly URL purge failed for ${url}: HTTP ${response.status} ${response.statusText}${ + body ? `: ${body}` : '' + }`, + ) + } +} + +// Purge every URL, bounded concurrency, collecting failures so one bad URL +// doesn't drop the rest. Throws at the end if any failed so the workflow's +// failure alerting fires. +export async function hardPurgeUrls( + urls: string[], + fastlyToken: string, + concurrency = PURGE_CONCURRENCY, +): Promise { + const queue = [...urls] + const errors: Error[] = [] + + async function worker() { + while (queue.length) { + const url = queue.shift() + if (!url) break + try { + console.log(`Hard-purging ${url}`) + await hardPurgeUrl(url, fastlyToken) + } catch (error) { + console.error(error) + errors.push(error instanceof Error ? error : new Error(String(error))) + } + } + } + + await Promise.all(Array.from({ length: Math.min(concurrency, urls.length) }, worker)) + + if (errors.length) { + throw new Error(`${errors.length} of ${urls.length} URL purge(s) failed`) + } +} + +async function main() { + const { FASTLY_TOKEN, HEAD_SHA } = process.env + if (!FASTLY_TOKEN) { + throw new Error('FASTLY_TOKEN not detected; refusing to purge') + } + + const context = getActionContext() + const owner: string = context.owner + const repo: string = context.repo + const headSha: string | undefined = HEAD_SHA || context.deployment?.sha + if (!headSha) { + throw new Error('Could not determine the deployed (head) SHA') + } + + const octokit = github() + + const baseSha = await resolvePreviousProductionSha(octokit, owner, repo, headSha) + if (!baseSha) { + // First-ever deploy, or we couldn't find a prior production deploy. The + // soft purge already ran, so just no-op rather than fail the workflow. + console.warn('No previous production deployment found; skipping targeted purge.') + return + } + console.log(`Diffing content between ${baseSha}..${headSha}`) + + const changedFiles = await getChangedContentFiles(octokit, owner, repo, baseSha, headSha) + if (changedFiles === null) { + console.warn( + `Change set is too large (>= ${COMPARE_FILE_LIMIT} files); ` + + 'relying on the soft purge of the whole language instead.', + ) + return + } + if (!changedFiles.length) { + console.log('No content/ files changed in this deploy; nothing to hard-purge.') + return + } + console.log(`${changedFiles.length} changed content file(s).`) + + const permalinkIndex = await loadEnglishPermalinkIndex() + const urls = contentFilesToEnglishUrls( + changedFiles, + (relativePath) => permalinkIndex.get(relativePath) || [], + ) + + if (!urls.length) { + console.log('Changed content files did not resolve to any English URLs; nothing to purge.') + return + } + if (urls.length > MAX_URLS) { + console.warn( + `Resolved ${urls.length} URLs (> ${MAX_URLS}); skipping targeted purge and ` + + 'relying on the soft purge of the whole language instead.', + ) + return + } + + console.log(`Hard-purging ${urls.length} English URL(s)...`) + await hardPurgeUrls(urls, FASTLY_TOKEN) + console.log('Done.') +} + +const isEntryPoint = + import.meta.url === `file://${process.argv[1]}` || + process.argv[1]?.endsWith('purge-fastly-changed-content.ts') + +if (isEntryPoint) { + await main() +} diff --git a/src/workflows/tests/purge-fastly-changed-content.ts b/src/workflows/tests/purge-fastly-changed-content.ts new file mode 100644 index 000000000000..8c14cf74b8b9 --- /dev/null +++ b/src/workflows/tests/purge-fastly-changed-content.ts @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import type { Octokit } from '@octokit/rest' + +const fetchWithRetry = vi.fn() +vi.mock('@/frame/lib/fetch-utils', () => ({ + fetchWithRetry: (...args: unknown[]) => fetchWithRetry(...args), +})) +// warmServer is only used by the entry point, not the exported helpers under +// test, but importing the module pulls it in, so stub it to stay light. +vi.mock('@/frame/lib/warm-server', () => ({ default: vi.fn() })) + +const { + resolvePreviousProductionSha, + getChangedContentFiles, + contentFilesToEnglishUrls, + hardPurgeUrls, +} = await import('../purge-fastly-changed-content') + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('resolvePreviousProductionSha', () => { + function makeOctokit( + deployments: Array<{ id: number; sha: string }>, + statuses: Record, + ) { + return { + rest: { + repos: { + listDeployments: vi.fn(async () => ({ data: deployments })), + listDeploymentStatuses: vi.fn(async ({ deployment_id }: { deployment_id: number }) => ({ + data: (statuses[deployment_id] || []).map((state) => ({ state })), + })), + }, + }, + } as unknown as Octokit + } + + test('returns the most recent prior deployment that reached production', async () => { + const octokit = makeOctokit( + [ + { id: 3, sha: 'head' }, + { id: 2, sha: 'prev' }, + { id: 1, sha: 'older' }, + ], + { 2: ['pending', 'success'], 1: ['success'] }, + ) + expect(await resolvePreviousProductionSha(octokit, 'github', 'docs-internal', 'head')).toBe( + 'prev', + ) + }) + + test('does not treat an inactive-only deployment as previously-live', async () => { + const octokit = makeOctokit( + [ + { id: 3, sha: 'never-live' }, + { id: 2, sha: 'prev' }, + ], + { 3: ['inactive'], 2: ['success', 'inactive'] }, + ) + expect(await resolvePreviousProductionSha(octokit, 'github', 'docs-internal', 'head')).toBe( + 'prev', + ) + }) + + test('skips deployments matching the head sha', async () => { + const octokit = makeOctokit([{ id: 3, sha: 'head' }], { 3: ['success'] }) + expect( + await resolvePreviousProductionSha(octokit, 'github', 'docs-internal', 'head'), + ).toBeNull() + }) + + test('returns null when no prior deployment ever succeeded', async () => { + const octokit = makeOctokit([{ id: 2, sha: 'prev' }], { 2: ['failure', 'error'] }) + expect( + await resolvePreviousProductionSha(octokit, 'github', 'docs-internal', 'head'), + ).toBeNull() + }) +}) + +describe('getChangedContentFiles', () => { + function makeOctokit(files: Array<{ filename: string; status: string }>) { + return { + rest: { + repos: { + compareCommitsWithBasehead: vi.fn(async () => ({ data: { files } })), + }, + }, + } as unknown as Octokit + } + + test('keeps changed/added content markdown, drops everything else', async () => { + const octokit = makeOctokit([ + { filename: 'content/get-started/foo.md', status: 'modified' }, + { filename: 'content/get-started/bar.md', status: 'added' }, + { filename: 'content/get-started/gone.md', status: 'removed' }, + { filename: 'content/get-started/readme.md', status: 'modified' }, + { filename: 'data/reusables/x.md', status: 'modified' }, + { filename: 'src/foo.ts', status: 'modified' }, + ]) + const result = await getChangedContentFiles(octokit, 'github', 'docs-internal', 'base', 'head') + expect(result).toEqual([ + { filename: 'content/get-started/foo.md', status: 'modified' }, + { filename: 'content/get-started/bar.md', status: 'added' }, + { filename: 'content/get-started/readme.md', status: 'modified' }, + ]) + }) + + test('returns null when the change set is too large', async () => { + const files = Array.from({ length: 300 }, (_unused, i) => ({ + filename: `content/x/file-${i}.md`, + status: 'modified', + })) + const octokit = makeOctokit(files) + expect( + await getChangedContentFiles(octokit, 'github', 'docs-internal', 'base', 'head'), + ).toBeNull() + }) +}) + +describe('contentFilesToEnglishUrls', () => { + test('maps content paths to full prod URLs across versions and dedupes', () => { + const permalinks: Record = { + 'get-started/foo.md': ['/en/get-started/foo', '/en/enterprise-server@3.14/get-started/foo'], + 'get-started/bar.md': ['/en/get-started/bar'], + } + const urls = contentFilesToEnglishUrls( + [ + { filename: 'content/get-started/foo.md', status: 'modified' }, + { filename: 'content/get-started/bar.md', status: 'added' }, + { filename: 'content/get-started/foo.md', status: 'modified' }, + ], + (relativePath) => permalinks[relativePath] || [], + ) + expect(urls).toEqual([ + 'https://docs.github.com/en/get-started/foo', + 'https://docs.github.com/en/enterprise-server@3.14/get-started/foo', + 'https://docs.github.com/en/get-started/bar', + ]) + }) + + test('skips files that resolve to no permalinks', () => { + const urls = contentFilesToEnglishUrls( + [{ filename: 'content/unknown/page.md', status: 'modified' }], + () => [], + ) + expect(urls).toEqual([]) + }) +}) + +describe('hardPurgeUrls', () => { + test('issues a hard URL purge per URL (no soft-purge header)', async () => { + fetchWithRetry.mockResolvedValue({ ok: true }) + await hardPurgeUrls( + ['https://docs.github.com/en/foo', 'https://docs.github.com/en/bar'], + 'token-123', + 2, + ) + expect(fetchWithRetry).toHaveBeenCalledTimes(2) + const [url, init] = fetchWithRetry.mock.calls[0] + expect(url).toBe('https://api.fastly.com/purge/docs.github.com/en/foo') + expect(init.method).toBe('POST') + expect(init.headers['fastly-key']).toBe('token-123') + expect(init.headers['fastly-soft-purge']).toBeUndefined() + }) + + test('throws if any purge fails, after attempting all of them', async () => { + fetchWithRetry.mockResolvedValueOnce({ ok: true }).mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'err', + text: async () => 'boom', + }) + await expect( + hardPurgeUrls(['https://docs.github.com/en/foo', 'https://docs.github.com/en/bar'], 'tok', 1), + ).rejects.toThrow(/1 of 2 URL purge\(s\) failed/) + expect(fetchWithRetry).toHaveBeenCalledTimes(2) + }) +}) From f6aa36b7c56d3ed2805367fb9f0b9c07f99d8d70 Mon Sep 17 00:00:00 2001 From: Lokesh Gopu Date: Wed, 24 Jun 2026 15:28:59 -0400 Subject: [PATCH 2/4] Document background steps keywords in Actions workflow syntax reference (#61865) Co-authored-by: Salil --- .../get-started/understand-github-actions.md | 4 + .../workflows-and-actions/workflow-syntax.md | 137 ++++++++++++++++++ .../manual-migrations/migrate-from-jenkins.md | 2 +- 3 files changed, 142 insertions(+), 1 deletion(-) diff --git a/content/actions/get-started/understand-github-actions.md b/content/actions/get-started/understand-github-actions.md index 6a003186b963..1c2b9e6bb539 100644 --- a/content/actions/get-started/understand-github-actions.md +++ b/content/actions/get-started/understand-github-actions.md @@ -71,6 +71,10 @@ For a complete list of events that can be used to trigger workflows, see [Events A **job** is a set of **steps** in a workflow that is executed on the same **runner**. Each step is either a shell script that will be executed, or an **action** that will be run. Steps are executed in order and are dependent on each other. Since each step is executed on the same runner, you can share data from one step to another. For example, you can have a step that builds your application followed by a step that tests the application that was built. +{% ifversion actions-nga %} +Steps run in order by default, but you can also run selected steps concurrently when your workflow benefits from parallel execution, such as starting a long-running service while later steps continue. For more information, see [AUTOTITLE](/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsbackground). +{% endif %} + You can configure a job's dependencies with other jobs; by default, jobs have no dependencies and run in parallel. When a job takes a dependency on another job, it waits for the dependent job to complete before running. You can also use a **matrix** to run the same job multiple times, each with a different combination of variables—like operating systems or language versions. diff --git a/content/actions/reference/workflows-and-actions/workflow-syntax.md b/content/actions/reference/workflows-and-actions/workflow-syntax.md index 9e9afea565cd..5ca629d02b49 100644 --- a/content/actions/reference/workflows-and-actions/workflow-syntax.md +++ b/content/actions/reference/workflows-and-actions/workflow-syntax.md @@ -903,6 +903,143 @@ The maximum number of minutes to run the step before killing the process. Maximu Fractional values are not supported. `timeout-minutes` must be a positive integer. +{% ifversion actions-nga %} + +## `jobs..steps[*].background` + +Runs a step asynchronously so the job continues to the next step without waiting for it to finish. Use `background: true` for long-running processes, such as databases, servers, or monitoring tasks, that need to run alongside other steps. You synchronize with background steps later using [`wait`](#jobsjob_idstepswait) or [`wait-all`](#jobsjob_idstepswait-all) or stop them with [`cancel`](#jobsjob_idstepscancel). + +You can use `background` on steps that use `run` or `uses`. To reference a background step from [`wait`](#jobsjob_idstepswait) or [`cancel`](#jobsjob_idstepscancel), give it an [`id`](#jobsjob_idstepsid). A maximum of 10 background steps can run concurrently in a single job; additional background steps are queued until a slot is free. + +Outputs and environment changes from a background step are only available after you run a `wait` or `wait-all` step that includes it. If a background step fails, the job fails at the next `wait` or `wait-all` that includes it (unless [`continue-on-error`](#jobsjob_idstepscontinue-on-error) is set on that step). An implicit `wait-all` runs before any post-job cleanup. + +Use `background` when you need fine-grained control: starting a long-running process (like a server or database) that stays up while later steps run, referencing a specific step with [`wait`](#jobsjob_idstepswait) or [`cancel`](#jobsjob_idstepscancel), or interleaving background work with other steps. If you instead have a self-contained group of steps that should all finish before the job continues, [`parallel`](#jobsjob_idstepsparallel) is a more convenient shorthand. + +### Example: Running a step in the background + +```yaml +steps: + - name: Start server + id: server + run: npm start + background: true + + - name: Run tests against the server + run: npm test + + - name: Wait for the server step to finish + wait: server +``` + +## `jobs..steps[*].wait` + +Pauses the job until one or more background steps complete. A `wait` step performs no work itself, it only blocks until the referenced background steps finish. Provide a single step `id` as a string, or multiple step `id`s as an array. + +After a `wait` step completes, the outputs of the referenced background steps become available to subsequent steps. If a referenced background step failed, the `wait` step fails too. + +### Example: Waiting for specific background steps + +```yaml +steps: + - name: Build frontend + id: build-frontend + run: npm run build:frontend + background: true + + - name: Build backend + id: build-backend + run: npm run build:backend + background: true + + - name: Run linter while builds run + run: npm run lint + + - name: Wait for both builds to finish + wait: [build-frontend, build-backend] + + - name: Run tests + run: npm test +``` + +## `jobs..steps[*].wait-all` + +Pauses the job until all active background steps complete. This is useful when several background steps are running and you want them all to finish before continuing. Like `wait`, the `wait-all` step fails if any of the background steps it waits on failed, unless you set [`continue-on-error`](#jobsjob_idstepscontinue-on-error) to `true`. + +The `wait-all` keyword takes no arguments. + +### Example: Waiting for all background steps + +```yaml +steps: + - name: Start database + id: db + run: docker run -d postgres:15 + background: true + + - name: Start cache + id: cache + run: docker run -d redis:7 + background: true + + - name: Run integration tests + run: npm run test:integration + + - name: Wait for all services to stop + wait-all: +``` + +## `jobs..steps[*].cancel` + +Gracefully terminates a running background step. The runner sends the step's process a termination signal (`SIGTERM`) so it can clean up, and forcibly stops it (`SIGKILL`) if it does not exit within a short grace period. The `cancel` keyword targets a single background step by its `id`. + +### Example: Canceling a background step + +```yaml +steps: + - name: Start long-running monitor + id: monitor + run: ./scripts/monitor.sh + background: true + + - name: Run the main task + run: npm test + + - name: Stop the monitor + cancel: monitor +``` + +## `jobs..steps[*].parallel` + +Runs a group of steps concurrently, then waits for all of them to finish before continuing. The `parallel` keyword is shorthand: every step in the group runs as a background step, with an implicit `wait` at the end of the group. Use it when you have an independent group of steps that can run at the same time and you don't need to reference them individually. + +Use `parallel` when you have a self-contained group of steps that should all finish before the job moves on, such as building several components at once. Use [`background`](#jobsjob_idstepsbackground) when you need finer control: starting a long-running process (like a server or database) that stays up while later steps run, referencing a specific step with [`wait`](#jobsjob_idstepswait) or [`cancel`](#jobsjob_idstepscancel), or interleaving background work with other steps. In short, `parallel` is more limited but more convenient for the "run this group at once" case, while `background` is the general-purpose primitive. + +Each step in the group is subject to the same 10-step concurrency limit as other background steps. + +### Example: Running steps in parallel + +```yaml +steps: + - uses: {% data reusables.actions.action-checkout %} + + - parallel: + - name: Build frontend + run: npm run build:frontend + + - name: Build backend + run: npm run build:backend + + - name: Build docs + run: npm run build:docs + + - name: Run tests after all builds complete + run: npm test +``` + +The group above is equivalent to declaring each step with `background: true` followed by a `wait` step. + +{% endif %} + ## `jobs..timeout-minutes` The maximum number of minutes to let a job run before {% data variables.product.prodname_dotcom %} automatically cancels it. Default: 360 diff --git a/content/actions/tutorials/migrate-to-github-actions/manual-migrations/migrate-from-jenkins.md b/content/actions/tutorials/migrate-to-github-actions/manual-migrations/migrate-from-jenkins.md index 4e93e09ab268..d4fe60810f35 100644 --- a/content/actions/tutorials/migrate-to-github-actions/manual-migrations/migrate-from-jenkins.md +++ b/content/actions/tutorials/migrate-to-github-actions/manual-migrations/migrate-from-jenkins.md @@ -76,7 +76,7 @@ Jenkins uses directives to manage _Declarative Pipelines_. These directives defi ### Parallel job processing -Jenkins can run the `stages` and `steps` in parallel, while {% data variables.product.prodname_actions %} currently only runs jobs in parallel. +{% ifversion actions-nga %}Jenkins can run the `stages` and `steps` in parallel. {% data variables.product.prodname_actions %} runs jobs in parallel and can also run steps concurrently within a job using step-level syntax. For more information, see [AUTOTITLE](/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsbackground).{% else %}Jenkins can run the `stages` and `steps` in parallel, while {% data variables.product.prodname_actions %} currently only runs jobs in parallel.{% endif %} | Jenkins Parallel | {% data variables.product.prodname_actions %} | | ------------- | ------------- | From ce1757fa86d28e7b3d10b9bdf94e803559262d5f Mon Sep 17 00:00:00 2001 From: Steve S Date: Wed, 24 Jun 2026 15:42:51 -0400 Subject: [PATCH 3/4] Fix CodeQL workflow failing on fork PRs in github/docs (#61889) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/codeql.yml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 64189fea0c50..3f5bdad4cc95 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -30,14 +30,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - name: Generate GitHub App token - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - app-id: ${{ secrets.DOCS_BOT_APP_ID }} - private-key: ${{ secrets.DOCS_BOT_APP_PRIVATE_KEY }} - owner: github - repositories: docs-engineering - uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7 with: @@ -50,6 +42,16 @@ jobs: with: slack_token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }} + - name: Generate GitHub App token + if: ${{ failure() && github.event_name != 'pull_request' }} + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.DOCS_BOT_APP_ID }} + private-key: ${{ secrets.DOCS_BOT_APP_PRIVATE_KEY }} + owner: github + repositories: docs-engineering + - uses: ./.github/actions/create-workflow-failure-issue if: ${{ failure() && github.event_name != 'pull_request' }} with: From 527dba2c59430cbd1bb4f829f207f695afc2508a Mon Sep 17 00:00:00 2001 From: Aaron Waggener <73763104+aaronwaggener@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:52:21 -0700 Subject: [PATCH 4/4] Evaluate hasExtendedMetadata Liquid in secret-scanning render paths (#61622) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tests/secret-scanning-transformer.test.ts | 78 +++++++++++++++++++ .../secret-scanning-transformer.ts | 12 ++- .../middleware/secret-scanning.ts | 8 +- .../tests/liquid-evaluation.ts | 67 ++++++++++++++++ src/types/types.ts | 2 +- 5 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 src/article-api/tests/secret-scanning-transformer.test.ts create mode 100644 src/secret-scanning/tests/liquid-evaluation.ts diff --git a/src/article-api/tests/secret-scanning-transformer.test.ts b/src/article-api/tests/secret-scanning-transformer.test.ts new file mode 100644 index 000000000000..8ec8886c2400 --- /dev/null +++ b/src/article-api/tests/secret-scanning-transformer.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest' + +import { SecretScanningTransformer } from '@/article-api/transformers/secret-scanning-transformer' +import shortVersionsMiddleware from '@/versions/middleware/short-versions' +import { allVersions } from '@/versions/lib/all-versions' +import enterpriseServerReleases from '@/versions/lib/enterprise-server-releases' +import { getSecretScanningData } from '@/secret-scanning/lib/get-secret-scanning-data' +import type { Context, ExtendedRequest, Page, SecretScanningData } from '@/types' + +vi.mock('@/secret-scanning/lib/get-secret-scanning-data') +vi.mock('@/article-api/lib/load-template', () => ({ + loadTemplate: () => '{{ content }}', +})) + +const ghesConditional = '{% ifversion ghes %}false{% else %}true{% endif %}' + +const makeEntry = (): SecretScanningData => + ({ + provider: 'Example', + supportedSecret: 'Example Token', + secretType: 'example_token', + versions: {}, + isPublic: true, + isPrivateWithGhas: true, + hasPushProtection: true, + hasValidityCheck: ghesConditional, + hasExtendedMetadata: ghesConditional, + base64Supported: false, + isduplicate: false, + }) as SecretScanningData + +const stubPage = { + autogenerated: 'secret-scanning', + title: 'Test', + intro: '', + render: vi.fn().mockResolvedValue(''), + renderProp: vi.fn().mockResolvedValue(''), +} as unknown as Page + +const buildContext = async (currentVersion: string): Promise => { + const req = { language: 'en', query: {} } as ExtendedRequest + req.context = { currentVersion, allVersions, enterpriseServerReleases } as Context + req.context.currentVersionObj = allVersions[currentVersion] + await shortVersionsMiddleware(req, null, () => {}) + return req.context +} + +describe('SecretScanningTransformer Liquid evaluation', () => { + const transformer = new SecretScanningTransformer() + + beforeEach(() => { + vi.clearAllMocks() + }) + + const oldestGhes = enterpriseServerReleases.oldestSupported + + test('resolves GHES conditionals to false on enterprise-server', async () => { + vi.mocked(getSecretScanningData).mockResolvedValue([makeEntry()]) + const context = await buildContext(`enterprise-server@${oldestGhes}`) + + await transformer.transform(stubPage, '/test', context) + + expect(context.secretScanningData).toBeDefined() + expect(context.secretScanningData![0].hasValidityCheck).toBe(false) + expect(context.secretScanningData![0].hasExtendedMetadata).toBe(false) + }) + + test('resolves GHES conditionals to true on free-pro-team', async () => { + vi.mocked(getSecretScanningData).mockResolvedValue([makeEntry()]) + const context = await buildContext('free-pro-team@latest') + + await transformer.transform(stubPage, '/test', context) + + expect(context.secretScanningData).toBeDefined() + expect(context.secretScanningData![0].hasValidityCheck).toBe(true) + expect(context.secretScanningData![0].hasExtendedMetadata).toBe(true) + }) +}) diff --git a/src/article-api/transformers/secret-scanning-transformer.ts b/src/article-api/transformers/secret-scanning-transformer.ts index 46bacb93686f..2d8550e9b50d 100644 --- a/src/article-api/transformers/secret-scanning-transformer.ts +++ b/src/article-api/transformers/secret-scanning-transformer.ts @@ -40,7 +40,7 @@ export class SecretScanningTransformer implements PageTransformer { // Process Liquid in values for (const entry of data) { - // Only process Liquid for the hasValidityCheck field, as in the middleware + // Process Liquid for the hasValidityCheck field, as in the middleware if (typeof entry.hasValidityCheck === 'string' && entry.hasValidityCheck.includes('{%')) { // Render Liquid and parse as YAML to get correct boolean type entry.hasValidityCheck = load( @@ -48,6 +48,16 @@ export class SecretScanningTransformer implements PageTransformer { ) as boolean } + // Process Liquid for the hasExtendedMetadata field, as in the middleware + if ( + typeof entry.hasExtendedMetadata === 'string' && + entry.hasExtendedMetadata.includes('{%') + ) { + entry.hasExtendedMetadata = load( + await liquid.parseAndRender(entry.hasExtendedMetadata, context), + ) as boolean + } + if (entry.isduplicate) { entry.secretType += '
Token versions' } diff --git a/src/secret-scanning/middleware/secret-scanning.ts b/src/secret-scanning/middleware/secret-scanning.ts index c72d4659c2aa..6d129c21000e 100644 --- a/src/secret-scanning/middleware/secret-scanning.ts +++ b/src/secret-scanning/middleware/secret-scanning.ts @@ -48,9 +48,13 @@ export default async function secretScanning( // to execute that Liquid to get the actual value. for (const entry of req.context.secretScanningData) { for (const [key, value] of Object.entries(entry)) { - if (key === 'hasValidityCheck' && typeof value === 'string' && value.includes('{%')) { + if ( + (key === 'hasValidityCheck' || key === 'hasExtendedMetadata') && + typeof value === 'string' && + value.includes('{%') + ) { const evaluated = yaml.load(await liquid.parseAndRender(value, req.context)) - entry[key] = evaluated as string + entry[key] = evaluated as boolean | string } } if (entry.isduplicate) { diff --git a/src/secret-scanning/tests/liquid-evaluation.ts b/src/secret-scanning/tests/liquid-evaluation.ts new file mode 100644 index 000000000000..63669f985fd3 --- /dev/null +++ b/src/secret-scanning/tests/liquid-evaluation.ts @@ -0,0 +1,67 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest' +import { readFileSync } from 'fs' +import type { Response } from 'express' + +import secretScanning from '@/secret-scanning/middleware/secret-scanning' +import shortVersionsMiddleware from '@/versions/middleware/short-versions' +import { allVersions } from '@/versions/lib/all-versions' +import enterpriseServerReleases from '@/versions/lib/enterprise-server-releases' +import { getSecretScanningData } from '@/secret-scanning/lib/get-secret-scanning-data' +import type { Context, ExtendedRequest, SecretScanningData } from '@/types' + +vi.mock('@/secret-scanning/lib/get-secret-scanning-data') + +const { targetFilename } = JSON.parse( + readFileSync('src/secret-scanning/lib/config.json', 'utf8'), +) as { targetFilename: string } + +// Both hasValidityCheck and hasExtendedMetadata can be emitted by token-scanning-service +// as a Liquid conditional that resolves to false on GHES and true elsewhere. +const ghesConditional = '{% ifversion ghes %}false{% else %}true{% endif %}' + +const makeEntry = (): SecretScanningData => + ({ + provider: 'Example', + supportedSecret: 'Example Token', + secretType: 'example_token', + versions: {}, + isPublic: true, + isPrivateWithGhas: true, + hasPushProtection: true, + hasValidityCheck: ghesConditional, + hasExtendedMetadata: ghesConditional, + base64Supported: false, + isduplicate: false, + }) as SecretScanningData + +const runMiddleware = async (currentVersion: string): Promise => { + vi.mocked(getSecretScanningData).mockResolvedValue([makeEntry()]) + + const req = { language: 'en', query: {}, pagePath: `/en/${targetFilename}` } as ExtendedRequest + req.context = { currentVersion, allVersions, enterpriseServerReleases } as Context + req.context.currentVersionObj = allVersions[currentVersion] + await shortVersionsMiddleware(req, null, () => {}) + + await secretScanning(req, {} as Response, () => {}) + return req.context.secretScanningData![0] +} + +describe('secret-scanning middleware Liquid evaluation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const oldestSupportedGhes = enterpriseServerReleases.oldestSupported + + test('resolves GHES conditionals to false on enterprise-server', async () => { + const entry = await runMiddleware(`enterprise-server@${oldestSupportedGhes}`) + expect(entry.hasValidityCheck).toBe(false) + expect(entry.hasExtendedMetadata).toBe(false) + }) + + test('resolves GHES conditionals to true on free-pro-team', async () => { + const entry = await runMiddleware('free-pro-team@latest') + expect(entry.hasValidityCheck).toBe(true) + expect(entry.hasExtendedMetadata).toBe(true) + }) +}) diff --git a/src/types/types.ts b/src/types/types.ts index d1472f619b8c..78345dc6023f 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -280,7 +280,7 @@ export type SecretScanningData = { isPrivateWithGhas: boolean hasPushProtection: boolean hasValidityCheck: boolean | string - hasExtendedMetadata?: boolean + hasExtendedMetadata?: boolean | string base64Supported: boolean isduplicate: boolean }