Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/tests/crowdin-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ interface CrowdinConfig {
const workflowPath = fileURLToPath(new URL('../workflows/i18n-crowdin.yml', import.meta.url));
const crowdinConfigPath = fileURLToPath(new URL('../../crowdin.yml', import.meta.url));
const crowdinActionRef = 'crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f';
const integrationBranchCheckoutStepName =
'Check out the integration branch so l10n_crowdin forks from it';

function loadCrowdinWorkflowStep(): WorkflowStep {
const workflow = loadWorkflow(workflowPath);
Expand All @@ -41,6 +43,33 @@ test('Crowdin action runs as workspace owner and surfaces sync failures', () =>
expect(step['continue-on-error']).toBeUndefined();
});

test('Crowdin checks out the resolved integration branch before creating its branch', () => {
const steps = loadWorkflow(workflowPath).jobs?.sync?.steps ?? [];
const baseStepIndex = steps.findIndex((step) => step.id === 'base');
const checkoutStepIndex = steps.findIndex(
(step) => step.name === integrationBranchCheckoutStepName,
);
const crowdinStepIndex = steps.findIndex((step) =>
step.uses?.startsWith('crowdin/github-action@'),
);
const checkoutStep = steps[checkoutStepIndex];

expect(baseStepIndex).toBeGreaterThanOrEqual(0);
expect(checkoutStepIndex).toBeGreaterThan(baseStepIndex);
expect(crowdinStepIndex).toBeGreaterThan(checkoutStepIndex);

expect(checkoutStep?.env?.BASE).toBe('${{ steps.base.outputs.name }}');
expect(checkoutStep?.run).not.toContain('${{ steps.base.outputs.name }}');

const fetchCommand = 'git fetch origin "refs/heads/${BASE}:refs/remotes/origin/${BASE}"';
const checkoutCommand = 'git checkout -B "${BASE}" "refs/remotes/origin/${BASE}"';
expect(checkoutStep?.run).toContain(fetchCommand);
expect(checkoutStep?.run).toContain(checkoutCommand);
expect(checkoutStep?.run?.indexOf(fetchCommand)).toBeLessThan(
checkoutStep?.run?.indexOf(checkoutCommand) ?? -1,
);
});

test('Crowdin workflow lets crowdin.yml own the target language list', () => {
const step = loadCrowdinWorkflowStep();
const config = yaml.parse(readFileSync(crowdinConfigPath, 'utf8')) as CrowdinConfig;
Expand Down
4 changes: 2 additions & 2 deletions .github/tests/harden-runner-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import yaml from 'yaml';
import type { WorkflowDefinition } from './workflow-test-utils';

const workflowsDir = fileURLToPath(new URL('../workflows', import.meta.url));
const hardenRunnerRef = 'step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411';
const hardenRunnerVersion = 'v2.19.4';
const hardenRunnerRef = 'step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920';
const hardenRunnerVersion = 'v2.20.0';

function loadWorkflowFiles(): Array<{
file: string;
Expand Down
144 changes: 144 additions & 0 deletions .github/tests/release-cut-ga-promotion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { fileURLToPath } from 'node:url';

import { loadWorkflow, type WorkflowStep } from './workflow-test-utils';

const workflowPath = fileURLToPath(new URL('../workflows/release-cut.yml', import.meta.url));

const prereleaseOnlySignSteps = [
'Sign release artifact',
'Verify release artifact signature',
'Attest release artifact provenance',
'Export release provenance asset',
'Verify release artifact provenance attestation',
];

const gaPromotionSteps = [
'Download candidate release artifact for promotion',
'Verify downloaded candidate artifact checksum',
'Verify candidate artifact provenance attestation',
'Verify candidate artifact signature',
'Verify candidate artifact is reproducible from source SHA',
'Promote candidate artifact to GA release filenames',
];

const assetSuffixes = [
'tar.gz',
'tar.gz.sha256',
'tar.gz.bundle',
'tar.gz.sig',
'tar.gz.pem',
'tar.gz.intoto.jsonl',
];

function loadReleaseSteps(): WorkflowStep[] {
const workflow = loadWorkflow(workflowPath);
return workflow.jobs?.release?.steps ?? [];
}

function getStep(name: string): WorkflowStep | undefined {
return loadReleaseSteps().find((step) => step.name === name);
}

test('release-cut captures a CHANGELOG snapshot from the target SHA', () => {
const step = getStep('Capture CHANGELOG snapshot from target SHA');

expect(step?.id).toBe('target_changelog');
expect(step?.run).toContain('git show "${TARGET_SHA}:CHANGELOG.md"');
// The extractor must be snapshotted as the whole scripts/ tree, not a single
// file: it imports relative siblings (./lib/parse-args.mjs), which Node
// resolves against the script's own location.
expect(step?.run).toContain('git archive "${TARGET_SHA}" scripts | tar -x -C');
expect(step?.run).not.toContain('git show "${TARGET_SHA}:scripts/');
});

test('release-cut reads CHANGELOG from the target-sha snapshot, not the checked-out tree', () => {
const validateStep = getStep('Validate CHANGELOG entry for release tag');
const notesStep = getStep('Generate release notes from changelog');

for (const step of [validateStep, notesStep]) {
expect(step?.env).toMatchObject({
CHANGELOG_PATH: '${{ steps.target_changelog.outputs.path }}',
});
expect(step?.run).toContain('--file "${CHANGELOG_PATH}"');
expect(step?.run).not.toContain('--file CHANGELOG.md');
}
});

test('release-cut gates artifact sign/attest/verify steps to prereleases only', () => {
for (const stepName of prereleaseOnlySignSteps) {
const step = getStep(stepName);

expect(step?.if).toBe("steps.tag.outputs.is_prerelease == 'true'");
}
});

test('release-cut promotes the candidate artifact at GA in a fixed step order, each GA-gated', () => {
const steps = loadReleaseSteps();
const indexOf = (name: string) => steps.findIndex((step) => step.name === name);

for (const stepName of gaPromotionSteps) {
const step = getStep(stepName);

expect(step, `expected step "${stepName}" to exist`).toBeDefined();
expect(step?.if).toBe("steps.tag.outputs.is_prerelease == 'false'");
}

const indices = gaPromotionSteps.map(indexOf);
for (let i = 1; i < indices.length; i += 1) {
expect(indices[i]).toBeGreaterThan(indices[i - 1]);
}
});

test('release-cut downloads and promotes exactly the six candidate asset suffixes', () => {
const downloadStep = getStep('Download candidate release artifact for promotion');
const promoteStep = getStep('Promote candidate artifact to GA release filenames');

// The download runs under the pinned retry wrapper (transient GitHub API /
// asset-CDN failures must not abort a GA run), so its script lives in
// `with.command`, not `run`.
expect(downloadStep?.uses).toContain('nick-fields/retry@');
const downloadCommand = String(downloadStep?.with?.command ?? '');
for (const suffix of assetSuffixes) {
expect(downloadCommand).toContain(`--pattern "drydock-\${CANDIDATE_TAG}.${suffix}"`);
}

expect(promoteStep?.run).toContain(
'for ext in tar.gz tar.gz.sha256 tar.gz.bundle tar.gz.sig tar.gz.pem tar.gz.intoto.jsonl',
);
});

test('release-cut verifies candidate provenance against SOURCE_SHA, keeping TARGET_SHA for prereleases', () => {
const candidateStep = getStep('Verify candidate artifact provenance attestation');
const prereleaseStep = getStep('Verify release artifact provenance attestation');

expect(candidateStep?.env).toMatchObject({
SOURCE_SHA: '${{ steps.source.outputs.source_sha }}',
});
expect(candidateStep?.run).toContain('--source-digest "${SOURCE_SHA}"');
expect(candidateStep?.run).not.toContain('--source-digest "${TARGET_SHA}"');

expect(prereleaseStep?.env).toMatchObject({
TARGET_SHA: '${{ steps.target.outputs.sha }}',
});
expect(prereleaseStep?.run).toContain('--source-digest "${TARGET_SHA}"');
});

test('release-cut compares decompressed tar streams for reproducibility, not raw gzip bytes', () => {
const reproStep = getStep('Verify candidate artifact is reproducible from source SHA');

expect(reproStep?.run).toContain('gzip -dc "${rebuilt}"');
expect(reproStep?.run).toContain('gzip -dc "${downloaded}"');
});

test('release-cut builds the GA-day release artifact under the candidate tag prefix', () => {
const buildStep = getStep('Build release artifact');

expect(buildStep?.env).toMatchObject({
CANDIDATE_TAG: '${{ inputs.candidate_tag }}',
IS_PRERELEASE: '${{ steps.tag.outputs.is_prerelease }}',
});
expect(buildStep?.run).toContain('if [ "${IS_PRERELEASE}" = "true" ]; then');
expect(buildStep?.run).toContain('archive_tag="${RELEASE_TAG}"');
expect(buildStep?.run).toContain('archive_tag="${CANDIDATE_TAG}"');
expect(buildStep?.run).toContain('artifact="dist/drydock-${archive_tag}.tar.gz"');
});
2 changes: 1 addition & 1 deletion .github/tests/release-cut-retry-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const changelogExtractorPath = fileURLToPath(
const changelogPath = fileURLToPath(new URL('../../CHANGELOG.md', import.meta.url));
const gitignorePath = fileURLToPath(new URL('../../.gitignore', import.meta.url));
const retryAction = 'nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60';
const metadataAction = 'docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9';
const metadataAction = 'docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302';
const transientRetryStepNames = [
'Retry GHCR login',
'Retry Docker Hub login',
Expand Down
Loading
Loading