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
105 changes: 104 additions & 1 deletion .github/scripts/link-check.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ test("link-check keeps one rolling issue and closes it after recovery", () => {
const closeStep = workflow.slice(closeIndex);
assert.match(
closeStep,
/if: steps\.lychee\.outputs\.exit_code == '0' && steps\.tracking\.outputs\.issue-number != ''/u,
/if: steps\.lychee\.outputs\.exit_code == '0' && steps\.tracking\.outputs\.issue-number != '' && inputs\.auto-close/u,
"recovery close must be gated on the auto-close toggle",
);
assert.match(
closeStep,
Expand Down Expand Up @@ -86,3 +87,105 @@ test("the tracking marker is scoped per configured report", () => {
"step computed, not a hardcoded constant",
);
});

test("behavior-shaping inputs default to the established rolling-issue behavior", () => {
const inputBlock = (name) => {
const match = workflow.match(
new RegExp(`\\n {6}${name}:\\n(?: {8}.*\\n)+`, "u"),
);
assert.ok(match, `workflow_call input '${name}' is missing`);
return match[0];
};

assert.match(inputBlock("issue-title"), /default: 'Link checker report'/u);
assert.match(
inputBlock("issue-labels"),
/default: 'automated, link-check'/u,
"existing callers must keep the historical label set",
);
assert.match(
inputBlock("issue-type"),
/default: ''/u,
"existing callers must keep their rolling issue untyped",
);
const autoClose = inputBlock("auto-close");
assert.match(autoClose, /type: boolean/u);
assert.match(
autoClose,
/default: true/u,
"existing callers must keep close-on-recovery",
);
});

test("the tracking lookup filters on the same labels the update step applies", () => {
const findIndex = workflow.indexOf("- name: Find existing tracking issue");
const embedIndex = workflow.indexOf(
"- name: Embed the marker in the lychee report",
);
assert.ok(findIndex >= 0 && embedIndex > findIndex);
const lookupStep = workflow.slice(findIndex, embedIndex);

assert.match(
lookupStep,
/ISSUE_LABELS: \$\{\{ inputs\.issue-labels \}\}/u,
"the lookup must derive its label filter from the caller-configured set",
);
assert.match(
lookupStep,
/labels_query="\$\(jq -rn --arg labels "\$ISSUE_LABELS"/u,
);
assert.match(
lookupStep,
/map\(@uri\) \| join\(","\)/u,
"each label must be percent-encoded individually so label text cannot " +
"split or terminate the query",
);
assert.match(
lookupStep,
/if \[\[ -z "\$labels_query" \]\]; then[\s\S]*?exit 1/u,
"an empty label set must fail closed, not silently drop the " +
"owned-report constraint",
);
assert.match(
lookupStep,
/labels=\$\{labels_query\}/u,
"the issues query must filter on the caller-configured labels",
);

const updateIndex = workflow.indexOf("- name: Open or update tracking issue");
const typeIndex = workflow.indexOf("- name: Assert native issue type");
assert.ok(updateIndex >= 0 && typeIndex > updateIndex);
const updateStep = workflow.slice(updateIndex, typeIndex);
assert.match(
updateStep,
/labels: \$\{\{ inputs\.issue-labels \}\}/u,
"the update step must apply exactly the configured label set",
);
});

test("the native issue type is asserted from input, after create or update", () => {
const updateIndex = workflow.indexOf("- name: Open or update tracking issue");
const typeIndex = workflow.indexOf("- name: Assert native issue type");
const closeIndex = workflow.indexOf("- name: Close recovered tracking issue");
assert.ok(
typeIndex > updateIndex,
"type assertion must follow the create/update step whose issue number " +
"it targets",
);
assert.ok(closeIndex > typeIndex);

const typeStep = workflow.slice(typeIndex, closeIndex);
assert.match(
typeStep,
/if: steps\.lychee\.outputs\.exit_code != '0' && inputs\.issue-type != ''/u,
"an empty issue-type must skip type assignment (the pre-input behavior)",
);
assert.match(
typeStep,
/NUMBER: \$\{\{ steps\.issue\.outputs\.issue-number \}\}/u,
);
assert.match(
typeStep,
/gh issue edit "\$NUMBER" --repo "\$GITHUB_REPOSITORY" --type "\$ISSUE_TYPE"/u,
);
});
79 changes: 65 additions & 14 deletions .github/workflows/link-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ name: link-check
# health is inherently flaky (transient outages, anti-bot 403/429), so this runs
# on a schedule with fail:false and maintains a rolling tracking issue instead
# of breaking a build. The issue is opened or updated while links are unhealthy
# and closed after recovery. Deterministic on-disk link integrity is the
# separate lychee-offline action that feeds ci-status. Call this from a scheduled
# caller workflow that grants issues: write.
# and, unless auto-close is disabled, closed after recovery. Deterministic
# on-disk link integrity is the separate lychee-offline action that feeds
# ci-status. Call this from a scheduled caller workflow that grants
# issues: write.
on:
workflow_call:
inputs:
Expand All @@ -30,6 +31,26 @@ on:
description: Title of the rolling issue opened on failure and closed on recovery.
type: string
default: 'Link checker report'
issue-labels:
description: >-
Comma- or newline-separated labels applied to the rolling issue. The
same set scopes the tracking-issue lookup, so it must name at least
one label and should stay distinctive enough that unrelated issues
do not carry all of them.
type: string
default: 'automated, link-check'
issue-type:
description: >-
Native issue type (for example Task) asserted on the rolling issue
whenever it is opened or updated. Empty skips type assignment.
type: string
default: ''
auto-close:
description: >-
Close the rolling issue after the next healthy run. Disable to leave
recovery triage to a human instead.
type: boolean
default: true

permissions:
contents: read
Expand Down Expand Up @@ -92,6 +113,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
ISSUE_TITLE: ${{ inputs.issue-title }}
ISSUE_LABELS: ${{ inputs.issue-labels }}
run: |
set -euo pipefail
gh_read() {
Expand All @@ -113,15 +135,31 @@ jobs:
report_key="$(printf '%s' "$ISSUE_TITLE" | sha256sum | cut -c1-12)"
marker="<!-- ci-workflows:link-check:v1:active:${report_key} -->"
echo "marker=${marker}" >> "$GITHUB_OUTPUT"
# labels=link-check restores the same-owned-report constraint the
# pre-marker workflow enforced via `gh issue list --label
# link-check`: without it, in any repo where issue creation isn't
# maintainer-only, an issue whose body or title happens to match by
# coincidence (or is crafted to match) could be adopted, then
# overwritten or closed by this automation. The label is applied by
# every version of the "Open or update tracking issue" step below,
# so it covers both the marker match and the title-fallback match.
open_issues="$(gh_read api --paginate --slurp "repos/${GITHUB_REPOSITORY}/issues?state=open&labels=link-check&per_page=100")"
# Filtering on the configured labels restores the same-owned-report
# constraint the pre-marker workflow enforced via `gh issue list
# --label link-check`: without it, in any repo where issue creation
# isn't maintainer-only, an issue whose body or title happens to
# match by coincidence (or is crafted to match) could be adopted,
# then overwritten or closed by this automation. The labels are
# applied by the "Open or update tracking issue" step below (create
# AND update, additively), so the filter covers both the marker
# match and the title-fallback match; an empty set would silently
# drop the constraint, so it fails closed. The normalization
# mirrors how create-issue-from-file parses its labels input
# (split on commas/newlines, trim, drop empties) so the lookup
# filters on exactly the set the update step applies; the
# list-issues API ANDs the comma-separated labels value, and each
# label is percent-encoded on its own so label text (spaces,
# colons) cannot split or terminate the query.
labels_query="$(jq -rn --arg labels "$ISSUE_LABELS" '
$labels | split("[,\n]"; "") | map(gsub("^\\s+|\\s+$"; ""))
| map(select(length > 0)) | map(@uri) | join(",")
')"
if [[ -z "$labels_query" ]]; then
echo '::error::issue-labels must name at least one label; the label filter is what keeps this automation from adopting, overwriting, or closing unrelated issues.'
exit 1
fi
open_issues="$(gh_read api --paginate --slurp "repos/${GITHUB_REPOSITORY}/issues?state=open&labels=${labels_query}&per_page=100")"
matches="$(jq -ce --arg marker "$marker" '
[.[][] | select(.pull_request? == null) | select((.body // "") | contains($marker)) | .number]
' <<<"$open_issues")"
Expand Down Expand Up @@ -160,16 +198,29 @@ jobs:
> "$RUNNER_TEMP/link-check-report.md"

- name: Open or update tracking issue
id: issue
if: steps.lychee.outputs.exit_code != '0'
uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # v6.0.0
with:
title: ${{ inputs.issue-title }}
issue-number: ${{ steps.tracking.outputs.issue-number }}
content-filepath: ${{ runner.temp }}/link-check-report.md
labels: automated, link-check
labels: ${{ inputs.issue-labels }}

# Native issue types are org-level metadata, distinct from labels, and
# create-issue-from-file cannot set one — assert it as a follow-up
# whenever the report is opened or updated (re-asserting on update heals
# a manual type change, matching how labels are re-applied above).
- name: Assert native issue type
if: steps.lychee.outputs.exit_code != '0' && inputs.issue-type != ''
env:
GH_TOKEN: ${{ github.token }}
NUMBER: ${{ steps.issue.outputs.issue-number }}
ISSUE_TYPE: ${{ inputs.issue-type }}
run: gh issue edit "$NUMBER" --repo "$GITHUB_REPOSITORY" --type "$ISSUE_TYPE"

- name: Close recovered tracking issue
if: steps.lychee.outputs.exit_code == '0' && steps.tracking.outputs.issue-number != ''
if: steps.lychee.outputs.exit_code == '0' && steps.tracking.outputs.issue-number != '' && inputs.auto-close
env:
GH_TOKEN: ${{ github.token }}
NUMBER: ${{ steps.tracking.outputs.issue-number }}
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,10 @@ GitHub continues the normal weekly patching of each hosted image generation.
via `uses:` at job level from a *scheduled* caller that grants `issues: write`.
It is **advisory**: external link health is flaky, so it runs `fail: false` and
maintains a rolling tracking issue rather than gating a build—opening or
updating it on failure and closing it after the next clean run. (A whole
updating it on failure and, by default, closing it after the next clean run.
Inputs (documented inline) let a caller shape the rolling issue — title,
labels, native issue type, and the auto-close toggle — so a repo with an
established issue scheme adopts the workflow without behavior change. (A whole
scheduled job with issue maintenance is a reusable-workflow concern, not a
composite action; the deterministic on-disk counterpart is the
`lychee-offline` action above, which feeds `ci-status`.)
Expand Down
Loading