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
27 changes: 26 additions & 1 deletion .github/scripts/__tests__/source-context.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ test('dependency repair promotion marker supplies explicit dependency source con
source_base_sha: 'a'.repeat(40),
source_head_sha: 'b'.repeat(40),
promotion_base_sha: 'c'.repeat(40),
ignored_untrusted_field: '__proto__',
}),
' -->',
].join('');
Expand All @@ -194,14 +195,38 @@ test('dependency repair promotion marker supplies explicit dependency source con
],
});

assert.equal(parseDependencyRepairPromotionSource(marker).source_pr, 2795);
assert.deepEqual(parseDependencyRepairPromotionSource(marker), {
source_pr: 2795,
source_base_sha: 'a'.repeat(40),
source_head_sha: 'b'.repeat(40),
promotion_base_sha: 'c'.repeat(40),
});
assert.equal(context.sourceType, SOURCE_TYPES.DEPENDABOT);
assert.equal(context.sourceRef, 'dependency-pr:#2795');
assert.equal(context.isExplicit, true);
assert.equal(context.isValid, true);
assert.equal(context.requiresIssue, false);
});

test('underscore dependency source label authorizes a valid promotion marker', () => {
const marker = `<!-- dependency-repair-promotion:v1 ${JSON.stringify({
source_pr: 2795,
source_base_sha: 'a'.repeat(40),
source_head_sha: 'b'.repeat(40),
promotion_base_sha: 'c'.repeat(40),
})} -->`;
const context = resolvePrSourceContext({
body: `${marker}\nFixes #99`,
labels: [
{ name: 'dependency:repair-promotion' },
{ name: 'workflow_source_dependabot' },
],
});

assert.equal(context.sourceType, SOURCE_TYPES.DEPENDABOT);
assert.equal(context.issueNumber, null);
});

test('dependency repair promotion marker suppresses incidental issue references', () => {
const marker = [
'<!-- dependency-repair-promotion:v1 ',
Expand Down
4 changes: 2 additions & 2 deletions .github/scripts/agents_pr_meta_keepalive.js
Original file line number Diff line number Diff line change
Expand Up @@ -570,11 +570,11 @@ async function detectKeepalive({ core, github, context, env = process.env }) {
return finalise();
}

const issueNumber = extractIssueNumberFromPull(pull);
const sourceContext = resolvePrSourceContext(pull);
const issueNumber = sourceContext.issueNumber;
if (issueNumber) {
outputs.issue = String(issueNumber);
}
const sourceContext = resolvePrSourceContext(pull);
if (sourceContext.isKnown) {
outputs.source_type = sourceContext.sourceType;
}
Expand Down
10 changes: 8 additions & 2 deletions .github/scripts/source_context.js
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,12 @@ function parseDependencyRepairPromotionSource(body) {
if (!Number.isInteger(sourcePr) || sourcePr <= 0 || !validShas) {
return null;
}
return { ...metadata, source_pr: sourcePr };
return {
source_pr: sourcePr,
source_base_sha: metadata.source_base_sha,
source_head_sha: metadata.source_head_sha,
promotion_base_sha: metadata.promotion_base_sha,
};
} catch {
return null;
}
Expand Down Expand Up @@ -387,7 +392,8 @@ function resolvePrSourceContext(pull = {}) {
const trustedDependencyRepairPromotion = Boolean(
dependencyRepairPromotion &&
labels.includes('dependency:repair-promotion') &&
labels.includes('workflow:source-dependabot'),
(labels.includes('workflow:source-dependabot') ||
labels.includes('workflow_source_dependabot')),
);
const extractedIssueNumber = extractIssueNumberFromPull(pull);
// Promotion provenance is authoritative: a coincidental issue reference must
Expand Down
42 changes: 38 additions & 4 deletions .github/workflows/pr-46-dependency-repair-contract.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ on:
# zizmor: ignore[dangerous-triggers] trusted reusable workflow reads only
# PR metadata and git objects
pull_request_target:
types: [opened, reopened, synchronize, edited]
# `labeled`/`unlabeled` are required: the controlled promotion workflow
# applies the two trust labels after the PR is opened, so without them the
# `opened` run skips the contract and the required check stays satisfied
# while provenance is never validated.
types: [opened, reopened, synchronize, edited, labeled, unlabeled]

permissions:
contents: read
Expand All @@ -19,7 +23,37 @@ jobs:
if: >-
github.event.pull_request.user.login == 'renovate[bot]' ||
github.event.pull_request.user.login == 'dependabot[bot]' ||
startsWith(github.event.pull_request.head.ref, 'renovate/') ||
startsWith(github.event.pull_request.head.ref, 'dependabot/') ||
contains(github.event.pull_request.body || '', 'dependency-repair-promotion:v1')
(github.event.pull_request.head.repo.full_name == github.repository &&
(startsWith(github.event.pull_request.head.ref, 'renovate/') ||
startsWith(github.event.pull_request.head.ref, 'dependabot/'))) ||
(github.event.pull_request.head.repo.full_name == github.repository &&
contains(github.event.pull_request.body || '', '<!-- dependency-repair-promotion:v1') &&
contains(github.event.pull_request.labels.*.name, 'dependency:repair-promotion') &&
(contains(github.event.pull_request.labels.*.name, 'workflow:source-dependabot') ||
contains(github.event.pull_request.labels.*.name, 'workflow_source_dependabot')))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
uses: stranske/Workflows/.github/workflows/reusable-19-dependency-repair-contract.yml@main

# `contract` is gated on the two trust labels, and a skipped job reports
# success. Without this guard a same-repository promotion marker whose labels
# were never applied — or were removed again — would merge with no provenance
# validation at all. Fail closed instead.
unauthorized-promotion-marker:
if: >-
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.user.login != 'renovate[bot]' &&
github.event.pull_request.user.login != 'dependabot[bot]' &&
contains(github.event.pull_request.body || '', '<!-- dependency-repair-promotion:v1') &&
!(contains(github.event.pull_request.labels.*.name, 'dependency:repair-promotion') &&
(contains(github.event.pull_request.labels.*.name, 'workflow:source-dependabot') ||
contains(github.event.pull_request.labels.*.name, 'workflow_source_dependabot')))
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Reject an unauthorized promotion marker
run: |
echo "::error::This pull request carries a dependency-repair-promotion marker" \
"without both controlled promotion labels (dependency:repair-promotion and" \
"workflow:source-dependabot). The provenance contract therefore cannot run." \
"Remove the marker, or let the controlled promotion workflow apply the trust" \
"labels so PR 46 can validate the source PR, ancestry, and patch identity."
exit 1
26 changes: 26 additions & 0 deletions docs/ops/DEPENDENCY_REPAIR_PROMOTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,29 @@ For promotion PRs, it verifies:
Later commits are deliberately allowed: those commits are where local coding
agents provide the repair. Normal CI, active review-thread checks, shared
template ownership, and dependency security review still apply.

### Why authorization lives in the caller workflow

`reusable-19-dependency-repair-contract.yml` cannot decline its own invocation:
a `workflow_call` workflow runs whenever the caller's job runs. The decision of
*whether* a PR is entitled to the contract therefore has to be a job-level `if:`
in the caller, `pr-46-dependency-repair-contract.yml`, where the consumer's own
`pull_request_target` payload (author, head repository, body marker, labels) is
available. This is not a consumer-template exception: the root copy and
`templates/consumer-repo/.github/workflows/pr-46-dependency-repair-contract.yml`
are byte-identical and kept that way by `.github/sync-manifest.yml`, so every
consumer evaluates the same authorization rule against its own PRs.

Two properties of that caller-side condition are load-bearing:

- **It must re-evaluate on label changes.** The promotion labels are applied
after the PR is opened, so the caller subscribes to `labeled` and `unlabeled`
in addition to `opened`/`reopened`/`synchronize`/`edited`. Without them the
`opened` run would skip the job while the marker was still unlabeled, the
required check would stay satisfied, and none of the verification above would
ever run.
- **It must require a same-repository head.** Marker text and labels are both
reachable from a fork PR, but the contract checks out the head SHA from the
base repository, so the marker path is gated on
`head.repo.full_name == github.repository` exactly like the branch-prefix
path.
Original file line number Diff line number Diff line change
Expand Up @@ -570,11 +570,11 @@ async function detectKeepalive({ core, github, context, env = process.env }) {
return finalise();
}

const issueNumber = extractIssueNumberFromPull(pull);
const sourceContext = resolvePrSourceContext(pull);
const issueNumber = sourceContext.issueNumber;
if (issueNumber) {
outputs.issue = String(issueNumber);
}
const sourceContext = resolvePrSourceContext(pull);
if (sourceContext.isKnown) {
outputs.source_type = sourceContext.sourceType;
}
Expand Down
10 changes: 8 additions & 2 deletions templates/consumer-repo/.github/scripts/source_context.js
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,12 @@ function parseDependencyRepairPromotionSource(body) {
if (!Number.isInteger(sourcePr) || sourcePr <= 0 || !validShas) {
return null;
}
return { ...metadata, source_pr: sourcePr };
return {
source_pr: sourcePr,
source_base_sha: metadata.source_base_sha,
source_head_sha: metadata.source_head_sha,
promotion_base_sha: metadata.promotion_base_sha,
};
} catch {
return null;
}
Expand Down Expand Up @@ -387,7 +392,8 @@ function resolvePrSourceContext(pull = {}) {
const trustedDependencyRepairPromotion = Boolean(
dependencyRepairPromotion &&
labels.includes('dependency:repair-promotion') &&
labels.includes('workflow:source-dependabot'),
(labels.includes('workflow:source-dependabot') ||
labels.includes('workflow_source_dependabot')),
);
const extractedIssueNumber = extractIssueNumberFromPull(pull);
// Promotion provenance is authoritative: a coincidental issue reference must
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ jobs:
tools
.github/scripts/error_classifier.js
.github/scripts/github-api-with-retry.js
sparse-checkout-cone-mode: false
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: Set up Python
if: steps.check-merged.outputs.merged == 'true'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ on:
# zizmor: ignore[dangerous-triggers] trusted reusable workflow reads only
# PR metadata and git objects
pull_request_target:
types: [opened, reopened, synchronize, edited]
# `labeled`/`unlabeled` are required: the controlled promotion workflow
# applies the two trust labels after the PR is opened, so without them the
# `opened` run skips the contract and the required check stays satisfied
# while provenance is never validated.
types: [opened, reopened, synchronize, edited, labeled, unlabeled]

permissions:
contents: read
Expand All @@ -19,7 +23,37 @@ jobs:
if: >-
github.event.pull_request.user.login == 'renovate[bot]' ||
github.event.pull_request.user.login == 'dependabot[bot]' ||
startsWith(github.event.pull_request.head.ref, 'renovate/') ||
startsWith(github.event.pull_request.head.ref, 'dependabot/') ||
contains(github.event.pull_request.body || '', 'dependency-repair-promotion:v1')
(github.event.pull_request.head.repo.full_name == github.repository &&
(startsWith(github.event.pull_request.head.ref, 'renovate/') ||
startsWith(github.event.pull_request.head.ref, 'dependabot/'))) ||
(github.event.pull_request.head.repo.full_name == github.repository &&
contains(github.event.pull_request.body || '', '<!-- dependency-repair-promotion:v1') &&
contains(github.event.pull_request.labels.*.name, 'dependency:repair-promotion') &&
(contains(github.event.pull_request.labels.*.name, 'workflow:source-dependabot') ||
contains(github.event.pull_request.labels.*.name, 'workflow_source_dependabot')))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
uses: stranske/Workflows/.github/workflows/reusable-19-dependency-repair-contract.yml@main

# `contract` is gated on the two trust labels, and a skipped job reports
# success. Without this guard a same-repository promotion marker whose labels
# were never applied — or were removed again — would merge with no provenance
# validation at all. Fail closed instead.
unauthorized-promotion-marker:
if: >-
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.user.login != 'renovate[bot]' &&
github.event.pull_request.user.login != 'dependabot[bot]' &&
contains(github.event.pull_request.body || '', '<!-- dependency-repair-promotion:v1') &&
!(contains(github.event.pull_request.labels.*.name, 'dependency:repair-promotion') &&
(contains(github.event.pull_request.labels.*.name, 'workflow:source-dependabot') ||
contains(github.event.pull_request.labels.*.name, 'workflow_source_dependabot')))
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Reject an unauthorized promotion marker
run: |
echo "::error::This pull request carries a dependency-repair-promotion marker" \
"without both controlled promotion labels (dependency:repair-promotion and" \
"workflow:source-dependabot). The provenance contract therefore cannot run." \
"Remove the marker, or let the controlled promotion workflow apply the trust" \
"labels so PR 46 can validate the source PR, ancestry, and patch identity."
exit 1
Loading