fix(security): close installer filesystem races

Use no-follow file descriptors for legacy Codex snapshots, verification, restoration, and marker cleanup. Quarantine candidate removals and verify inode identity before deletion.

Carry the lifecycle runner as a verified artifact so privileged release workflows never dynamically check out and execute an output-selected revision.
This commit is contained in:
haelyra
2026-08-13 16:59:06 -04:00
parent 1db5c8ab4a
commit bab38ae91b
4 changed files with 181 additions and 113 deletions
+2 -14
View File
@@ -16,7 +16,6 @@ jobs:
dist_tag: ${{ steps.npm_publish_state.outputs.dist_tag }} dist_tag: ${{ steps.npm_publish_state.outputs.dist_tag }}
package_file: ${{ steps.pack.outputs.package_file }} package_file: ${{ steps.pack.outputs.package_file }}
package_sha256: ${{ steps.pack.outputs.package_sha256 }} package_sha256: ${{ steps.pack.outputs.package_sha256 }}
release_commit: ${{ steps.source.outputs.release_commit }}
steps: steps:
- name: Checkout - name: Checkout
@@ -25,12 +24,6 @@ jobs:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
- name: Pin release source
id: source
run: |
RELEASE_COMMIT=$(git rev-parse HEAD)
echo "release_commit=${RELEASE_COMMIT}" >> "$GITHUB_OUTPUT"
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
@@ -121,6 +114,7 @@ jobs:
path: | path: |
release_body.md release_body.md
${{ steps.pack.outputs.package_file }} ${{ steps.pack.outputs.package_file }}
tests/ci/packed-artifact-lifecycle.js
if-no-files-found: error if-no-files-found: error
- name: Verify existing npm artifact matches candidate - name: Verify existing npm artifact matches candidate
@@ -145,12 +139,6 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Checkout lifecycle test
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.verify.outputs.release_commit }}
persist-credentials: false
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
@@ -166,7 +154,7 @@ jobs:
env: env:
ECC_RELEASE_PACKAGE: release-artifacts/${{ needs.verify.outputs.package_file }} ECC_RELEASE_PACKAGE: release-artifacts/${{ needs.verify.outputs.package_file }}
ECC_RELEASE_SHA256: ${{ needs.verify.outputs.package_sha256 }} ECC_RELEASE_SHA256: ${{ needs.verify.outputs.package_sha256 }}
run: node tests/ci/packed-artifact-lifecycle.js run: node release-artifacts/tests/ci/packed-artifact-lifecycle.js
publish: publish:
name: Publish Release name: Publish Release
+2 -14
View File
@@ -39,7 +39,6 @@ jobs:
dist_tag: ${{ steps.npm_publish_state.outputs.dist_tag }} dist_tag: ${{ steps.npm_publish_state.outputs.dist_tag }}
package_file: ${{ steps.pack.outputs.package_file }} package_file: ${{ steps.pack.outputs.package_file }}
package_sha256: ${{ steps.pack.outputs.package_sha256 }} package_sha256: ${{ steps.pack.outputs.package_sha256 }}
release_commit: ${{ steps.source.outputs.release_commit }}
steps: steps:
- name: Checkout - name: Checkout
@@ -49,12 +48,6 @@ jobs:
ref: refs/tags/${{ inputs.tag }} ref: refs/tags/${{ inputs.tag }}
persist-credentials: false persist-credentials: false
- name: Pin release source
id: source
run: |
RELEASE_COMMIT=$(git rev-parse HEAD)
echo "release_commit=${RELEASE_COMMIT}" >> "$GITHUB_OUTPUT"
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
@@ -138,6 +131,7 @@ jobs:
path: | path: |
release_body.md release_body.md
${{ steps.pack.outputs.package_file }} ${{ steps.pack.outputs.package_file }}
tests/ci/packed-artifact-lifecycle.js
if-no-files-found: error if-no-files-found: error
- name: Verify existing npm artifact matches candidate - name: Verify existing npm artifact matches candidate
@@ -162,12 +156,6 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Checkout lifecycle test
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.verify.outputs.release_commit }}
persist-credentials: false
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
@@ -183,7 +171,7 @@ jobs:
env: env:
ECC_RELEASE_PACKAGE: release-artifacts/${{ needs.verify.outputs.package_file }} ECC_RELEASE_PACKAGE: release-artifacts/${{ needs.verify.outputs.package_file }}
ECC_RELEASE_SHA256: ${{ needs.verify.outputs.package_sha256 }} ECC_RELEASE_SHA256: ${{ needs.verify.outputs.package_sha256 }}
run: node tests/ci/packed-artifact-lifecycle.js run: node release-artifacts/tests/ci/packed-artifact-lifecycle.js
publish: publish:
name: Publish Release name: Publish Release
+174 -81
View File
@@ -14,8 +14,87 @@ function getStatePath(codexHome) {
return path.join(codexHome, 'ecc', 'legacy-sync-state.json'); return path.join(codexHome, 'ecc', 'legacy-sync-state.json');
} }
function digestFile(filePath) { function openRegularFileNoFollow(filePath, writable = false) {
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); const noFollow = fs.constants.O_NOFOLLOW || 0;
const flags = (writable ? fs.constants.O_RDWR : fs.constants.O_RDONLY) | noFollow;
let descriptor;
try {
descriptor = fs.openSync(filePath, flags);
} catch (error) {
if (error.code === 'ENOENT') return null;
if (error.code === 'ELOOP') {
throw new Error(`Refusing to manage non-regular legacy sync path: ${filePath}`);
}
throw error;
}
const stat = fs.fstatSync(descriptor);
if (!stat.isFile()) {
fs.closeSync(descriptor);
throw new Error(`Refusing to manage non-regular legacy sync path: ${filePath}`);
}
return { descriptor, stat };
}
function readRegularFileNoFollow(filePath, encoding = null) {
const opened = openRegularFileNoFollow(filePath);
if (!opened) return null;
try {
return {
content: fs.readFileSync(opened.descriptor, encoding || undefined),
mode: opened.stat.mode & 0o777,
};
} finally {
fs.closeSync(opened.descriptor);
}
}
function replaceOpenedRegularFile(opened, content, mode = null) {
const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content);
fs.ftruncateSync(opened.descriptor, 0);
fs.writeSync(opened.descriptor, buffer, 0, buffer.length, 0);
if (mode) fs.fchmodSync(opened.descriptor, mode);
fs.fsyncSync(opened.descriptor);
}
function createRegularFileNoFollow(filePath, content, mode = 0o600) {
const noFollow = fs.constants.O_NOFOLLOW || 0;
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow;
const descriptor = fs.openSync(filePath, flags, mode);
try {
const stat = fs.fstatSync(descriptor);
if (!stat.isFile()) {
throw new Error(`Refusing to create non-regular legacy sync path: ${filePath}`);
}
fs.writeFileSync(descriptor, content);
fs.fchmodSync(descriptor, mode);
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
}
function removeOpenedRegularFile(filePath, opened) {
const quarantineDir = fs.mkdtempSync(path.join(path.dirname(filePath), '.ecc-remove-'));
const quarantinePath = path.join(quarantineDir, path.basename(filePath));
fs.renameSync(filePath, quarantinePath);
const quarantined = openRegularFileNoFollow(quarantinePath);
const openedStat = fs.fstatSync(opened.descriptor, { bigint: true });
const quarantinedStat = fs.fstatSync(quarantined.descriptor, { bigint: true });
fs.closeSync(quarantined.descriptor);
if (quarantinedStat.dev !== openedStat.dev || quarantinedStat.ino !== openedStat.ino) {
try {
fs.linkSync(quarantinePath, filePath);
fs.unlinkSync(quarantinePath);
fs.rmdirSync(quarantineDir);
} catch (_restoreError) {
throw new Error(
`Legacy sync path changed before removal; preserved replacement at ${quarantinePath}`
);
}
throw new Error(`Legacy sync path changed before removal: ${filePath}`);
}
fs.unlinkSync(quarantinePath);
fs.rmdirSync(quarantineDir);
} }
function atomicWriteJson(filePath, value) { function atomicWriteJson(filePath, value) {
@@ -26,7 +105,18 @@ function atomicWriteJson(filePath, value) {
} }
function readState(statePath) { function readState(statePath) {
const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); const snapshot = readRegularFileNoFollow(statePath, 'utf8');
if (!snapshot) throw new Error(`Legacy Codex sync state not found at ${statePath}`);
return parseState(snapshot.content, statePath);
}
function readStateIfPresent(statePath) {
const snapshot = readRegularFileNoFollow(statePath, 'utf8');
return snapshot ? parseState(snapshot.content, statePath) : null;
}
function parseState(content, statePath) {
const state = JSON.parse(content);
if (state.schema !== SCHEMA || !Array.isArray(state.paths)) { if (state.schema !== SCHEMA || !Array.isArray(state.paths)) {
throw new Error(`Invalid legacy Codex sync state at ${statePath}`); throw new Error(`Invalid legacy Codex sync state at ${statePath}`);
} }
@@ -68,30 +158,14 @@ function getTrustedRoot(state, filePath) {
} }
function snapshotLegacyPath(filePath) { function snapshotLegacyPath(filePath) {
let previousContentBase64 = null; const snapshot = readRegularFileNoFollow(filePath);
let previousMode = null; const previousType = snapshot ? 'file' : 'missing';
let previousType = 'missing';
try {
const stat = fs.lstatSync(filePath);
if (stat.isFile()) {
previousType = 'file';
previousContentBase64 = fs.readFileSync(filePath).toString('base64');
previousMode = stat.mode & 0o777;
} else {
previousType = stat.isSymbolicLink() ? 'symlink' : 'other';
}
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
if (previousType === 'symlink' || previousType === 'other') {
throw new Error(`Refusing to manage non-regular legacy sync path: ${filePath}`);
}
return { return {
path: filePath, path: filePath,
installedSha256: null, installedSha256: null,
previousType, previousType,
previousContentBase64, previousContentBase64: snapshot ? snapshot.content.toString('base64') : null,
previousMode, previousMode: snapshot ? snapshot.mode : null,
}; };
} }
@@ -102,17 +176,15 @@ function assertInstalledStateUnmodified(state) {
if (!trustedRoot || hasUnsafeManagedAncestor(filePath, trustedRoot)) { if (!trustedRoot || hasUnsafeManagedAncestor(filePath, trustedRoot)) {
throw new Error(`Refusing to reuse unsafe legacy Codex ownership path: ${filePath}`); throw new Error(`Refusing to reuse unsafe legacy Codex ownership path: ${filePath}`);
} }
let stat = null; const snapshot = readRegularFileNoFollow(filePath);
try {
stat = fs.lstatSync(filePath);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
if (!entry.installedSha256) { if (!entry.installedSha256) {
if (stat) throw new Error(`Refusing to replace modified legacy Codex artifact: ${filePath}`); if (snapshot) throw new Error(`Refusing to replace modified legacy Codex artifact: ${filePath}`);
continue; continue;
} }
if (!stat || !stat.isFile() || digestFile(filePath) !== entry.installedSha256) { const digest = snapshot
? crypto.createHash('sha256').update(snapshot.content).digest('hex')
: null;
if (digest !== entry.installedSha256) {
throw new Error(`Refusing to replace modified legacy Codex artifact: ${filePath}`); throw new Error(`Refusing to replace modified legacy Codex artifact: ${filePath}`);
} }
} }
@@ -124,7 +196,7 @@ function beginLegacySyncState(options) {
const configPath = path.join(codexHome, 'config.toml'); const configPath = path.join(codexHome, 'config.toml');
const agentsPath = path.join(codexHome, 'AGENTS.md'); const agentsPath = path.join(codexHome, 'AGENTS.md');
const installedHooksPath = options.installedHooksPath ? path.resolve(options.installedHooksPath) : null; const installedHooksPath = options.installedHooksPath ? path.resolve(options.installedHooksPath) : null;
const priorState = fs.existsSync(statePath) ? readState(statePath) : null; const priorState = readStateIfPresent(statePath);
if (priorState && priorState.status !== 'installed') { if (priorState && priorState.status !== 'installed') {
throw new Error(`Legacy Codex sync state requires recovery before reinstall: ${statePath}`); throw new Error(`Legacy Codex sync state requires recovery before reinstall: ${statePath}`);
} }
@@ -161,15 +233,8 @@ function beginLegacySyncState(options) {
for (const [key, filePath] of [['config', configPath], ['agents', agentsPath]]) { for (const [key, filePath] of [['config', configPath], ['agents', agentsPath]]) {
if (priorState) break; if (priorState) break;
if (fs.existsSync(filePath)) { const snapshot = readRegularFileNoFollow(filePath, 'utf8');
const stat = fs.lstatSync(filePath); state.before[key] = snapshot ? snapshot.content : null;
if (!stat.isFile()) {
throw new Error(`Refusing to snapshot non-regular legacy sync path: ${filePath}`);
}
state.before[key] = fs.readFileSync(filePath, 'utf8');
} else {
state.before[key] = null;
}
} }
atomicWriteJson(statePath, state); atomicWriteJson(statePath, state);
return statePath; return statePath;
@@ -210,31 +275,38 @@ function rollbackLegacyCodexSync(options) {
retainedPaths.push(filePath); retainedPaths.push(filePath);
continue; continue;
} }
let currentStat = null; let opened = null;
try { try {
currentStat = fs.lstatSync(filePath); opened = openRegularFileNoFollow(filePath, true);
} catch (error) { } catch (_error) {
if (error.code !== 'ENOENT') throw error;
}
if (currentStat && !currentStat.isFile() && !currentStat.isSymbolicLink()) {
retainedPaths.push(filePath); retainedPaths.push(filePath);
continue; continue;
} }
if (entry.previousType === 'file' && typeof entry.previousContentBase64 === 'string') { if (entry.previousType === 'file' && typeof entry.previousContentBase64 === 'string') {
if (currentStat && currentStat.isSymbolicLink()) { const previousContent = Buffer.from(entry.previousContentBase64, 'base64');
retainedPaths.push(filePath); const previousMode = entry.previousMode || 0o600;
continue;
}
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
fs.writeFileSync(filePath, Buffer.from(entry.previousContentBase64, 'base64'), { if (opened) {
mode: entry.previousMode || 0o600, try {
}); replaceOpenedRegularFile(opened, previousContent, previousMode);
if (entry.previousMode) fs.chmodSync(filePath, entry.previousMode); } finally {
fs.closeSync(opened.descriptor);
}
} else {
createRegularFileNoFollow(filePath, previousContent, previousMode);
}
restoredPaths.push(filePath); restoredPaths.push(filePath);
} else if (entry.previousType === 'missing' || entry.previousType === undefined) { } else if (entry.previousType === 'missing' || entry.previousType === undefined) {
if (currentStat) fs.rmSync(filePath, { force: true }); if (opened) {
try {
removeOpenedRegularFile(filePath, opened);
} finally {
fs.closeSync(opened.descriptor);
}
}
restoredPaths.push(filePath); restoredPaths.push(filePath);
} else { } else {
if (opened) fs.closeSync(opened.descriptor);
retainedPaths.push(filePath); retainedPaths.push(filePath);
} }
} }
@@ -272,15 +344,21 @@ function finalizeLegacySyncState(options) {
delete state.rollbackPaths; delete state.rollbackPaths;
delete state.rollbackPreviousHooksPath; delete state.rollbackPreviousHooksPath;
delete state.previousInstalledState; delete state.previousInstalledState;
state.paths = state.paths.map(entry => ({ state.paths = state.paths.map(entry => {
...entry, const trustedRoot = getTrustedRoot(state, path.resolve(entry.path));
installedSha256: getTrustedRoot(state, path.resolve(entry.path)) let installedSha256 = null;
&& !hasUnsafeManagedAncestor(entry.path, getTrustedRoot(state, path.resolve(entry.path))) if (trustedRoot && !hasUnsafeManagedAncestor(entry.path, trustedRoot)) {
&& fs.existsSync(entry.path) try {
&& fs.lstatSync(entry.path).isFile() const snapshot = readRegularFileNoFollow(entry.path);
? digestFile(entry.path) installedSha256 = snapshot
: null, ? crypto.createHash('sha256').update(snapshot.content).digest('hex')
})); : null;
} catch (_error) {
installedSha256 = null;
}
}
return { ...entry, installedSha256 };
});
atomicWriteJson(options.statePath, state); atomicWriteJson(options.statePath, state);
return state; return state;
} }
@@ -374,21 +452,24 @@ function uninstallLegacyCodexSync(options = {}) {
const plannedRemovals = []; const plannedRemovals = [];
const removedPaths = []; const removedPaths = [];
const agentsPath = path.join(codexHome, 'AGENTS.md'); const agentsPath = path.join(codexHome, 'AGENTS.md');
const state = fs.existsSync(statePath) ? readState(statePath) : null; const state = readStateIfPresent(statePath);
if (!state) { if (!state) {
if (fs.existsSync(agentsPath)) { let openedAgents = null;
const agentsStat = fs.lstatSync(agentsPath); try {
if (!agentsStat.isFile()) { openedAgents = openRegularFileNoFollow(agentsPath, !dryRun);
retainedPaths.push(agentsPath); if (openedAgents) {
} else { const content = fs.readFileSync(openedAgents.descriptor, 'utf8');
const content = fs.readFileSync(agentsPath, 'utf8');
const stripped = stripMarkerBlock(content); const stripped = stripMarkerBlock(content);
if (stripped !== content) { if (stripped !== content) {
plannedRemovals.push(`${agentsPath}#ecc-marker-block`); plannedRemovals.push(`${agentsPath}#ecc-marker-block`);
if (!dryRun) fs.writeFileSync(agentsPath, stripped, 'utf8'); if (!dryRun) replaceOpenedRegularFile(openedAgents, stripped, openedAgents.stat.mode & 0o777);
} }
} }
} catch (_error) {
retainedPaths.push(agentsPath);
} finally {
if (openedAgents) fs.closeSync(openedAgents.descriptor);
} }
retainedPaths.push(...listLegacyCandidates(codexHome)); retainedPaths.push(...listLegacyCandidates(codexHome));
return { return {
@@ -414,29 +495,41 @@ function uninstallLegacyCodexSync(options = {}) {
retainedPaths.push(filePath); retainedPaths.push(filePath);
continue; continue;
} }
if (!fs.existsSync(filePath)) continue; let opened = null;
const currentStat = fs.lstatSync(filePath); try {
const matches = entry.installedSha256 && currentStat.isFile() opened = openRegularFileNoFollow(filePath, !dryRun);
? digestFile(filePath) === entry.installedSha256 } catch (_error) {
retainedPaths.push(filePath);
continue;
}
if (!opened) continue;
const currentContent = fs.readFileSync(opened.descriptor);
const matches = entry.installedSha256
? crypto.createHash('sha256').update(currentContent).digest('hex') === entry.installedSha256
: false; : false;
if (!matches) { if (!matches) {
fs.closeSync(opened.descriptor);
retainedPaths.push(filePath); retainedPaths.push(filePath);
continue; continue;
} }
plannedRemovals.push(filePath); plannedRemovals.push(filePath);
if (!dryRun) { if (!dryRun) {
if (entry.previousType === 'file' && typeof entry.previousContentBase64 === 'string') { if (entry.previousType === 'file' && typeof entry.previousContentBase64 === 'string') {
fs.writeFileSync(filePath, Buffer.from(entry.previousContentBase64, 'base64'), { replaceOpenedRegularFile(
mode: entry.previousMode || 0o600, opened,
}); Buffer.from(entry.previousContentBase64, 'base64'),
entry.previousMode || 0o600
);
} else if (entry.previousType === 'missing' || entry.previousType === undefined) { } else if (entry.previousType === 'missing' || entry.previousType === undefined) {
fs.rmSync(filePath, { force: true }); removeOpenedRegularFile(filePath, opened);
} else { } else {
fs.closeSync(opened.descriptor);
retainedPaths.push(filePath); retainedPaths.push(filePath);
continue; continue;
} }
removedPaths.push(filePath); removedPaths.push(filePath);
} }
fs.closeSync(opened.descriptor);
} }
if (state.installedHooksPath) { if (state.installedHooksPath) {
@@ -58,8 +58,6 @@ for (const workflowPath of workflowPaths) {
assert.match(source, /package_sha256:\s*\$\{\{ steps\.pack\.outputs\.package_sha256 \}\}/); assert.match(source, /package_sha256:\s*\$\{\{ steps\.pack\.outputs\.package_sha256 \}\}/);
assert.match(source, /createHash\(['"]sha256['"]\)/); assert.match(source, /createHash\(['"]sha256['"]\)/);
assert.match(source, /package_sha256=['"]? \+ digest/); assert.match(source, /package_sha256=['"]? \+ digest/);
assert.match(source, /release_commit:\s*\$\{\{ steps\.source\.outputs\.release_commit \}\}/);
assert.match(source, /release_commit=\$\{RELEASE_COMMIT\}/);
}); });
test(`${workflowPath} invokes only test files present in the release source`, () => { test(`${workflowPath} invokes only test files present in the release source`, () => {
@@ -80,6 +78,7 @@ for (const workflowPath of workflowPaths) {
assert.ok(uploadIndex > packIndex, 'artifact upload must happen after pack and hash'); assert.ok(uploadIndex > packIndex, 'artifact upload must happen after pack and hash');
assert.match(verify, /name:\s*ecc-release-artifacts/); assert.match(verify, /name:\s*ecc-release-artifacts/);
assert.match(verify, /\$\{\{ steps\.pack\.outputs\.package_file \}\}/); assert.match(verify, /\$\{\{ steps\.pack\.outputs\.package_file \}\}/);
assert.match(verify, /tests\/ci\/packed-artifact-lifecycle\.js/);
}); });
test(`${workflowPath} fails retries when npm already has different bytes`, () => { test(`${workflowPath} fails retries when npm already has different bytes`, () => {
@@ -102,8 +101,8 @@ for (const workflowPath of workflowPaths) {
assert.match(lifecycle, /name:\s*ecc-release-artifacts/); assert.match(lifecycle, /name:\s*ecc-release-artifacts/);
assert.match(lifecycle, /ECC_RELEASE_PACKAGE:\s*release-artifacts\/\$\{\{ needs\.verify\.outputs\.package_file \}\}/); assert.match(lifecycle, /ECC_RELEASE_PACKAGE:\s*release-artifacts\/\$\{\{ needs\.verify\.outputs\.package_file \}\}/);
assert.match(lifecycle, /ECC_RELEASE_SHA256:\s*\$\{\{ needs\.verify\.outputs\.package_sha256 \}\}/); assert.match(lifecycle, /ECC_RELEASE_SHA256:\s*\$\{\{ needs\.verify\.outputs\.package_sha256 \}\}/);
assert.match(lifecycle, /node tests\/ci\/packed-artifact-lifecycle\.js/); assert.match(lifecycle, /node release-artifacts\/tests\/ci\/packed-artifact-lifecycle\.js/);
assert.match(lifecycle, /ref:\s*\$\{\{ needs\.verify\.outputs\.release_commit \}\}/); assert.doesNotMatch(lifecycle, /actions\/checkout@/);
assert.doesNotMatch(lifecycle, /\bsecrets\s*:/, 'lifecycle job must not receive secrets'); assert.doesNotMatch(lifecycle, /\bsecrets\s*:/, 'lifecycle job must not receive secrets');
assert.doesNotMatch(lifecycle, /\$\{\{\s*secrets\./, 'lifecycle job must not reference secrets'); assert.doesNotMatch(lifecycle, /\$\{\{\s*secrets\./, 'lifecycle job must not reference secrets');
}); });