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
2 changes: 0 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ on:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:

permissions:
Expand Down
72 changes: 66 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -167,17 +167,77 @@ jobs:
exit 0
fi

if npm whoami --registry=https://registry.npmjs.org >/dev/null 2>&1; then
npm token revoke "${NPM_BOOTSTRAP_TOKEN}"
if npm whoami --registry=https://registry.npmjs.org >/dev/null 2>&1; then
echo "::error::The one-time npm token still authenticates after revocation."
probe_token_state() {
local phase="$1"
local authenticating=0
local non_authenticating=0
local inconclusive=0
local attempt
local whoami_output
local whoami_status

for attempt in 1 2 3; do
if ! npm ping --registry=https://registry.npmjs.org >/dev/null 2>&1; then
echo "::warning::${phase} token probe ${attempt}/3 could not confirm npm registry liveness."
inconclusive=$((inconclusive + 1))
else
set +e
whoami_output=$(npm whoami --registry=https://registry.npmjs.org 2>&1)
whoami_status=$?
set -e

if [ "${whoami_status}" -eq 0 ]; then
authenticating=$((authenticating + 1))
elif grep -Eqi '(E401|ENEEDAUTH|401 Unauthorized|must be logged in)' <<<"${whoami_output}"; then
non_authenticating=$((non_authenticating + 1))
else
echo "::warning::${phase} token probe ${attempt}/3 returned an inconclusive npm whoami error."
inconclusive=$((inconclusive + 1))
fi
fi

if [ "${attempt}" -lt 3 ]; then
sleep $((attempt * 2))
fi
done

if [ "${inconclusive}" -ne 0 ] || { [ "${authenticating}" -ne 0 ] && [ "${non_authenticating}" -ne 0 ]; }; then
echo "::error::${phase} token probes were mixed or inconclusive (authenticating=${authenticating}, non-authenticating=${non_authenticating}, inconclusive=${inconclusive}). Manually revoke the token on npmjs.com right now."
exit 1
fi
echo "One-time npm token revoked. Delete the NPM_BOOTSTRAP_TOKEN GitHub secret."
else

if [ "${authenticating}" -eq 3 ]; then
TOKEN_STATE="authenticating"
return
fi
if [ "${non_authenticating}" -eq 3 ]; then
TOKEN_STATE="non-authenticating"
return
fi

echo "::error::${phase} token probes did not reach a conclusive result. Manually revoke the token on npmjs.com right now."
exit 1
}

probe_token_state "Pre-revocation"
if [ "${TOKEN_STATE}" = "non-authenticating" ]; then
echo "The supplied npm token is already non-authenticating; no revocation remains."
exit 0
fi

if ! npm token revoke "${NPM_BOOTSTRAP_TOKEN}"; then
echo "::error::npm CLI token revocation failed. Manually revoke the token on npmjs.com right now."
exit 1
fi

probe_token_state "Post-revocation"
if [ "${TOKEN_STATE}" != "non-authenticating" ]; then
echo "::error::The one-time npm token still authenticates after revocation. Manually revoke the token on npmjs.com right now."
exit 1
fi

echo "One-time npm token revoked. Delete the NPM_BOOTSTRAP_TOKEN GitHub secret."

publish-prerelease:
if: >-
vars.NPM_PUBLISH_ENABLED == 'true' &&
Expand Down
6 changes: 4 additions & 2 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Perform these steps in order:
```

6. Review destination organization rulesets, Actions permissions, Dependabot, private vulnerability reporting, and the `SEMGREP_APP_TOKEN` secret. Confirm pull requests and the `CI` and `Semgrep` checks still work after transfer. Confirm the package manifest's repository URL is exactly `git+https://github.com/shpitdev/foundry-ai.git`; npm requires an exact, case-sensitive repository match for provenance and trusted publishing.
7. On npmjs.com, create a one-day granular access token with read/write access to the `@shpit` scope and bypass-2FA enabled. This token exists only to create the brand-new public package. Copy it directly into the prompted secret input without putting it in shell history:
7. On npmjs.com, create a one-day granular access token with read/write access to the `@shpit` scope and bypass-2FA enabled. Ensure the token can manage and revoke itself. This token exists only to create the brand-new public package. Copy it directly into the prompted secret input without putting it in shell history:

```bash
gh secret set NPM_BOOTSTRAP_TOKEN --repo shpitdev/foundry-ai
Expand Down Expand Up @@ -93,7 +93,9 @@ Perform these steps in order:
test "$(git rev-list -n 1 '@shpit/foundry-ai@0.0.5')" = "${BOOTSTRAP_SHA}"
```

10. The workflow revokes the token and verifies that it no longer authenticates. If that step is not green, manually revoke the token on npmjs.com before doing anything else. Then remove the now-invalid GitHub secret:
10. The workflow automatically revokes the granular token with npm CLI 11.16 or newer, then verifies non-authentication with repeated registry-liveness checks and retry backoff. npm has stated that it expects to restrict bypass-2FA and other sensitive-action permissions on granular tokens sometime in 2026; if that change prevents CLI self-revocation, this workflow step hard-fails by design.

If the revocation step is red, manually revoke the token on npmjs.com immediately before doing anything else. Manual revocation is the only fallback when CLI revocation or verification cannot prove the token is inactive. After revocation is confirmed, remove the now-invalid GitHub secret:

```bash
gh secret delete NPM_BOOTSTRAP_TOKEN --repo shpitdev/foundry-ai
Expand Down
44 changes: 42 additions & 2 deletions scripts/check-release-policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ requireCondition(
bootstrapJob?.['runs-on'] === 'ubuntu-latest',
'Bootstrap must use a GitHub runner.',
);
requireCondition(
bootstrapJob?.if?.includes("github.event_name == 'workflow_dispatch'"),
'Bootstrap job must only run for workflow_dispatch.',
);
requireCondition(
bootstrapJob?.if?.includes("github.ref == 'refs/heads/main'"),
'Bootstrap dispatch must run from the main workflow ref.',
Expand Down Expand Up @@ -139,8 +143,12 @@ requireCondition(
'Token cleanup must distinguish a skipped safe resume from a publish attempt.',
);
requireCondition(
revokeStep.run?.includes(`npm token revoke "${shellVariable('NPM_BOOTSTRAP_TOKEN')}"`),
'Bootstrap must revoke the temporary npm token.',
revokeStep.run?.includes(`if ! npm token revoke "${shellVariable('NPM_BOOTSTRAP_TOKEN')}"; then`),
'Bootstrap must hard-fail when npm CLI token revocation fails.',
);
requireCondition(
!revokeStep.run?.includes('/-/npm/v1/tokens/token/'),
'Bootstrap must not use the classic-token registry API fallback.',
);
requireCondition(
revokeStep.run?.includes('npm whoami'),
Expand All @@ -150,15 +158,47 @@ requireCondition(
revokeStep.run?.includes('already non-authenticating'),
'Bootstrap token cleanup must be idempotent after an earlier accepted revocation.',
);
for (const requiredCheck of [
'npm ping --registry=https://registry.npmjs.org',
'for attempt in 1 2 3',
'sleep $((attempt * 2))',
'probe_token_state "Post-revocation"',
'mixed or inconclusive',
'Manually revoke the token on npmjs.com right now.',
]) {
requireCondition(
revokeStep.run?.includes(requiredCheck),
`Bootstrap token cleanup is missing retry hardening: ${requiredCheck}`,
);
}

for (const jobName of ['publish-prerelease', 'publish-stable']) {
const publishJob = workflow.jobs?.[jobName];
requireCondition(
publishJob?.if?.includes("vars.NPM_PUBLISH_ENABLED == 'true'"),
`${jobName} must require NPM_PUBLISH_ENABLED=true.`,
);
requireCondition(
publishJob?.if?.includes('github.event.pull_request.merged == true'),
`${jobName} must only publish merged pull requests.`,
);
requireCondition(
publishJob?.steps?.some((step) => step.run === 'npm install --global npm@11.16.0'),
`${jobName} must pin a trusted-publishing-compatible npm version.`,
);
}

const releaseBranchCheck = "startsWith(github.event.pull_request.head.ref, 'release/')";
requireCondition(
workflow.jobs?.['publish-prerelease']?.if?.includes(`!${releaseBranchCheck}`),
'Prerelease publishing must exclude release branches.',
);
requireCondition(
workflow.jobs?.['publish-stable']?.if?.includes(releaseBranchCheck) &&
!workflow.jobs?.['publish-stable']?.if?.includes(`!${releaseBranchCheck}`),
'Stable publishing must require a release branch.',
);

for (const [jobName, job] of Object.entries(workflow.jobs)) {
for (const step of job.steps ?? []) {
if (step.run == null) {
Expand Down
3 changes: 3 additions & 0 deletions scripts/reconcile-bootstrap-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@ export async function inspectPublishedPackage({
}

const attestationUrl = metadata.dist?.attestations?.url;
if (attestationUrl == null) {
throw new BootstrapUnavailableError('Published package attestation URL is not available yet.');
}
assertMatch(
typeof attestationUrl === 'string' &&
attestationUrl.startsWith(`${registry}/-/npm/v1/attestations/`),
Expand Down
94 changes: 91 additions & 3 deletions scripts/reconcile-bootstrap-release.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
import { describe, expect, it, vi } from 'vitest';
import {
BootstrapMismatchError,
BootstrapUnavailableError,
inspectBootstrapState,
reconcileBootstrapRelease,
} from './reconcile-bootstrap-release.mjs';
Expand Down Expand Up @@ -52,14 +53,23 @@ function provenanceStatement({ repositoryUrl = repository, sha = commitSha } = {
};
}

function registryResponses(options = {}) {
function registryMetadataResponse(options = {}) {
const dist = { integrity };
if (!options.omitAttestationUrl) {
dist.attestations = { url: options.attestationUrl ?? attestationUrl };
}
const metadata = {
name: packageName,
version,
gitHead: options.metadataSha ?? commitSha,
repository: { url: 'git+https://github.com/shpitdev/foundry-ai.git' },
dist: { integrity, attestations: { url: attestationUrl } },
dist,
};

return new Response(JSON.stringify(metadata), { status: 200 });
}

function registryResponses(options = {}) {
const attestationDocument = {
attestations: [
{
Expand All @@ -75,7 +85,7 @@ function registryResponses(options = {}) {
};

return [
new Response(JSON.stringify(metadata), { status: 200 }),
registryMetadataResponse(options),
new Response(JSON.stringify(attestationDocument), { status: 200 }),
];
}
Expand Down Expand Up @@ -184,4 +194,82 @@ describe('bootstrap release reconciliation', () => {
).rejects.toBeInstanceOf(BootstrapMismatchError);
expect(run).not.toHaveBeenCalled();
});

it('retries until a lagging attestation becomes available', async () => {
const fetchImpl = fetchSequence([
registryMetadataResponse({ omitAttestationUrl: true }),
...registryResponses(),
]);
const sleep = vi.fn().mockResolvedValue(undefined);
const { run } = gitRunner();

const reconciled = await reconcileBootstrapRelease({
attempts: 2,
commitSha,
delayMs: 25,
fetchImpl,
packageName,
repository,
run,
sleep,
version,
});

expect(reconciled.tagState).toMatchObject({ action: 'created', targetSha: commitSha });
expect(fetchImpl).toHaveBeenCalledTimes(3);
expect(sleep).toHaveBeenCalledOnce();
expect(sleep).toHaveBeenCalledWith(25);
});

it('reports unavailable after exhausting retries for a missing attestation', async () => {
const fetchImpl = fetchSequence([
registryMetadataResponse({ omitAttestationUrl: true }),
registryMetadataResponse({ omitAttestationUrl: true }),
registryMetadataResponse({ omitAttestationUrl: true }),
]);
const sleep = vi.fn().mockResolvedValue(undefined);
const { run } = gitRunner();

await expect(
reconcileBootstrapRelease({
attempts: 3,
commitSha,
delayMs: 25,
fetchImpl,
packageName,
repository,
run,
sleep,
version,
}),
).rejects.toBeInstanceOf(BootstrapUnavailableError);
expect(fetchImpl).toHaveBeenCalledTimes(3);
expect(sleep).toHaveBeenCalledTimes(2);
expect(run).not.toHaveBeenCalled();
});

it('rejects a malformed present attestation URL without retrying', async () => {
const fetchImpl = fetchSequence([
registryMetadataResponse({ attestationUrl: 'https://example.com/attestation' }),
]);
const sleep = vi.fn().mockResolvedValue(undefined);
const { run } = gitRunner();

await expect(
reconcileBootstrapRelease({
attempts: 3,
commitSha,
delayMs: 25,
fetchImpl,
packageName,
repository,
run,
sleep,
version,
}),
).rejects.toBeInstanceOf(BootstrapMismatchError);
expect(fetchImpl).toHaveBeenCalledOnce();
expect(sleep).not.toHaveBeenCalled();
expect(run).not.toHaveBeenCalled();
});
});