Skip to content

Commit a1f48d5

Browse files
authored
[BRE-2027] Adding shared action for uploading modified files (#833)
1 parent 9616739 commit a1f48d5

4 files changed

Lines changed: 377 additions & 0 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
---
2+
name: Test Upload Modified Files
3+
4+
on:
5+
workflow_dispatch:
6+
pull_request:
7+
paths:
8+
- "upload-modified-files/**"
9+
- ".github/workflows/test-upload-modified-files.yml"
10+
11+
permissions: {}
12+
13+
jobs:
14+
upload-and-verify:
15+
name: Upload And Verify
16+
runs-on: ubuntu-24.04
17+
permissions:
18+
contents: read
19+
steps:
20+
- name: Checkout
21+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
22+
with:
23+
persist-credentials: false
24+
25+
- name: Modify tracked files at different depths
26+
run: |
27+
echo "modified by test at $(date -u +%s)" >> README.md
28+
echo "modified by test at $(date -u +%s)" >> upload-modified-files/README.md
29+
30+
- name: Upload modified files
31+
id: upload
32+
uses: ./upload-modified-files
33+
with:
34+
artifact_name: test-modified-files
35+
36+
- name: Verify outputs
37+
env:
38+
_COUNT: ${{ steps.upload.outputs.count }}
39+
_MODIFIED_FILES: ${{ steps.upload.outputs.modified_files }}
40+
run: |
41+
echo "Count: $_COUNT"
42+
echo "Modified files:"
43+
echo "$_MODIFIED_FILES"
44+
if [[ "$_COUNT" != "2" ]]; then
45+
echo "::error::Expected 2 modified files, got '$_COUNT'"
46+
exit 1
47+
fi
48+
49+
download-and-verify:
50+
name: Download And Verify
51+
runs-on: ubuntu-24.04
52+
needs: upload-and-verify
53+
permissions:
54+
contents: read
55+
actions: read
56+
steps:
57+
- name: Download artifact
58+
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
59+
with:
60+
name: test-modified-files
61+
path: downloaded
62+
63+
- name: Extract and verify preserved paths
64+
run: |
65+
mkdir restored
66+
tar -xzf downloaded/modified-files.tar.gz -C restored
67+
echo "Restored tree:"
68+
find restored -type f
69+
70+
if [[ ! -f restored/README.md ]]; then
71+
echo "::error::Expected restored/README.md to exist"
72+
exit 1
73+
fi
74+
if [[ ! -f restored/upload-modified-files/README.md ]]; then
75+
echo "::error::Expected restored/upload-modified-files/README.md (nested path) to be preserved"
76+
exit 1
77+
fi
78+
if ! grep -q "modified by test at" restored/upload-modified-files/README.md; then
79+
echo "::error::Restored nested file is missing the modification"
80+
exit 1
81+
fi
82+
83+
fail-on-added:
84+
name: Fail On Added File
85+
runs-on: ubuntu-24.04
86+
permissions:
87+
contents: read
88+
steps:
89+
- name: Checkout
90+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
91+
with:
92+
persist-credentials: false
93+
94+
- name: Create an untracked file
95+
run: echo "brand new" > a-new-untracked-file.txt
96+
97+
- name: Run action (expected to fail)
98+
id: upload
99+
continue-on-error: true
100+
uses: ./upload-modified-files
101+
with:
102+
artifact_name: should-not-upload-added
103+
104+
- name: Assert the action failed
105+
env:
106+
_OUTCOME: ${{ steps.upload.outcome }}
107+
run: |
108+
if [[ "$_OUTCOME" != "failure" ]]; then
109+
echo "::error::Expected the action to fail on an added file, but outcome was '$_OUTCOME'"
110+
exit 1
111+
fi
112+
echo "Action correctly failed on an added file."
113+
114+
fail-on-deleted:
115+
name: Fail On Deleted File
116+
runs-on: ubuntu-24.04
117+
permissions:
118+
contents: read
119+
steps:
120+
- name: Checkout
121+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
122+
with:
123+
persist-credentials: false
124+
125+
- name: Delete a tracked file
126+
run: rm README.md
127+
128+
- name: Run action (expected to fail)
129+
id: upload
130+
continue-on-error: true
131+
uses: ./upload-modified-files
132+
with:
133+
artifact_name: should-not-upload-deleted
134+
135+
- name: Assert the action failed
136+
env:
137+
_OUTCOME: ${{ steps.upload.outcome }}
138+
run: |
139+
if [[ "$_OUTCOME" != "failure" ]]; then
140+
echo "::error::Expected the action to fail on a deleted file, but outcome was '$_OUTCOME'"
141+
exit 1
142+
fi
143+
echo "Action correctly failed on a deleted file."

upload-modified-files/README.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Upload Modified Files Action
2+
3+
## Description
4+
5+
The `upload-modified-files` action detects files that have been modified in the working tree of a checked-out repository and uploads them to the current workflow run as an artifact. It is designed for the pattern where one repository (for example, a build repo) produces changes to tracked files — such as version bumps — and a downstream repository (for example, a deploy repo) downloads those changes and commits them back using a bot with direct push access.
6+
7+
The action runs in two steps:
8+
9+
1. **Detect modified files.** Inspects `git status` and collects modifications to existing tracked files. It **fails if any files are added (new/untracked) or deleted**, because the downstream consumer commits the files over an existing checkout and cannot safely reconcile additions or removals.
10+
2. **Upload to the workflow run.** Archives the modified files into a `tar.gz` that preserves their exact repo-relative paths, then uploads it as an artifact.
11+
12+
## Key Features
13+
14+
- **Modification-only safety check**: Fails fast on added or deleted files so only in-place edits are propagated.
15+
- **Exact path preservation**: Files are archived with their full repo-relative paths, so they restore to the same structure when extracted in another repo — even when the changes span subdirectories.
16+
- **Simple, self-contained**: Pure `git` and `tar`, no external dependencies beyond what GitHub-hosted runners provide.
17+
18+
## How to Use It
19+
20+
Add this step after a step that modifies tracked files (e.g. a version bump). The repository must already be checked out.
21+
22+
```yaml
23+
- name: Checkout
24+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
25+
with:
26+
persist-credentials: false
27+
28+
# ... step(s) that modify tracked files, e.g. a version bump ...
29+
30+
- name: Upload modified files
31+
id: upload
32+
uses: bitwarden/gh-actions/upload-modified-files@main
33+
with:
34+
artifact_name: modified-files
35+
retention_days: '7'
36+
```
37+
38+
### Inputs
39+
40+
| Input | Required | Default | Description |
41+
| ---------------- | -------- | ---------------- | -------------------------------------------------------- |
42+
| `artifact_name` | No | `modified-files` | Name of the artifact to upload the modified files under. |
43+
| `retention_days` | No | `7` | Number of days to retain the uploaded artifact. |
44+
45+
### Outputs
46+
47+
| Output | Description |
48+
| ---------------- | ------------------------------------------------------------ |
49+
| `artifact_name` | Name of the artifact the modified files were uploaded under. |
50+
| `count` | Number of modified files detected and uploaded. |
51+
| `modified_files` | Newline-separated list of modified file paths. |
52+
53+
## Consuming the Artifact
54+
55+
The artifact contains a single file, `modified-files.tar.gz`, holding the modified files at their original repo-relative paths. In a downstream repository, download it, extract it over the checkout, and commit:
56+
57+
```yaml
58+
- name: Checkout target repo
59+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
60+
61+
- name: Download modified files
62+
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
63+
with:
64+
name: modified-files
65+
path: modified-files-tmp
66+
github-token: ${{ steps.app-token.outputs.token }}
67+
run-id: ${{ github.event.workflow_run.id }}
68+
repository: bitwarden/source-repo
69+
70+
- name: Apply and commit
71+
run: |
72+
tar -xzf modified-files-tmp/modified-files.tar.gz
73+
git add --update
74+
git commit -m "Apply modified files from upstream run"
75+
git push
76+
```
77+
78+
> For cross-repo downloads, generate a GitHub App token scoped to the source repository and pass it to `actions/download-artifact` via `github-token`, along with the upstream `run-id` and `repository`.
79+
80+
## Requirements
81+
82+
- The repository must be checked out before this action runs (`actions/checkout`), with the modifications present in the working tree.
83+
- `git` and `tar` must be available on the runner (present by default on GitHub-hosted runners).
84+
85+
## Troubleshooting
86+
87+
### "Detected added/untracked files, which are not supported"
88+
89+
- The working tree contains new files that are not tracked by git. This action only uploads modifications to existing tracked files. Remove the new files or handle them separately.
90+
91+
### "Detected deleted files, which are not supported"
92+
93+
- A tracked file was removed from the working tree. The downstream consumer cannot safely apply deletions; revert the deletion or handle it separately.
94+
95+
### "No modified files detected. Nothing to upload."
96+
97+
- The working tree is clean. Confirm the step that is supposed to modify files ran before this action and actually changed something.
98+
99+
### Nested paths are missing after download
100+
101+
- Ensure you extract `modified-files.tar.gz` from the repository root in the downstream job so the preserved repo-relative paths land in the right place.

upload-modified-files/action.yml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
name: "Upload Modified Files"
3+
description: "Detect git-modified files and upload them to the workflow run as an artifact."
4+
5+
inputs:
6+
artifact_name:
7+
description: "Name of the artifact to upload the modified files under."
8+
default: "modified-files"
9+
retention_days:
10+
description: "Number of days to retain the uploaded artifact."
11+
default: "7"
12+
13+
outputs:
14+
artifact_name:
15+
description: "Name of the artifact the modified files were uploaded under."
16+
value: ${{ inputs.artifact_name }}
17+
count:
18+
description: "Number of modified files detected and uploaded."
19+
value: ${{ steps.detect.outputs.count }}
20+
modified_files:
21+
description: "Newline-separated list of modified file paths."
22+
value: ${{ steps.detect.outputs.modified_files }}
23+
24+
runs:
25+
using: "composite"
26+
steps:
27+
- name: Detect modified files
28+
id: detect
29+
shell: bash
30+
env:
31+
MODIFIED_FILES_LIST: ${{ runner.temp }}/modified_files_list.txt
32+
run: bash "${{ github.action_path }}/detect-modified-files.sh"
33+
34+
- name: Archive modified files
35+
shell: bash
36+
env:
37+
MODIFIED_FILES_LIST: ${{ runner.temp }}/modified_files_list.txt
38+
ARCHIVE_PATH: ${{ runner.temp }}/modified-files.tar.gz
39+
run: |
40+
# Archive from the workspace so paths are stored repo-relative and
41+
# restore to the same structure when extracted downstream.
42+
tar -czf "$ARCHIVE_PATH" -T "$MODIFIED_FILES_LIST"
43+
echo "Created archive at $ARCHIVE_PATH"
44+
45+
- name: Upload modified files
46+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
47+
with:
48+
name: ${{ inputs.artifact_name }}
49+
path: ${{ runner.temp }}/modified-files.tar.gz
50+
retention-days: ${{ inputs.retention_days }}
51+
if-no-files-found: error
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# Detect git-modified files in the working tree and emit them for upload.
5+
#
6+
# Only modifications to existing tracked files are supported. The action fails
7+
# if any files are added (new/untracked) or deleted, because the downstream
8+
# consumer commits the downloaded files over an existing checkout and cannot
9+
# safely reconcile additions or removals.
10+
#
11+
# Required environment:
12+
# MODIFIED_FILES_LIST - path to write the newline-separated list of modified
13+
# files to (consumed by tar in the next step).
14+
# GITHUB_OUTPUT - set by GitHub Actions; receives `count` and
15+
# `modified_files` outputs.
16+
17+
modified=()
18+
added=()
19+
deleted=()
20+
other=()
21+
22+
# core.quotepath=false keeps non-ASCII paths unquoted so tar can read them.
23+
while IFS= read -r line; do
24+
[[ -z "$line" ]] && continue
25+
status="${line:0:2}"
26+
path="${line:3}"
27+
x="${status:0:1}"
28+
y="${status:1:1}"
29+
30+
if [[ "$status" == "??" ]]; then
31+
added+=("$path")
32+
elif [[ "$x" == "A" || "$y" == "A" ]]; then
33+
added+=("$path")
34+
elif [[ "$x" == "D" || "$y" == "D" ]]; then
35+
deleted+=("$path")
36+
elif [[ "$x" == "R" || "$y" == "R" || "$x" == "C" || "$y" == "C" ]]; then
37+
other+=("$path ($status)")
38+
elif [[ "$x" == "M" || "$y" == "M" || "$x" == "T" || "$y" == "T" ]]; then
39+
modified+=("$path")
40+
else
41+
other+=("$path ($status)")
42+
fi
43+
done < <(git -c core.quotepath=false status --porcelain)
44+
45+
fail=0
46+
if [[ ${#added[@]} -gt 0 ]]; then
47+
echo "::error::Detected added/untracked files, which are not supported:"
48+
printf '::error:: %s\n' "${added[@]}"
49+
fail=1
50+
fi
51+
if [[ ${#deleted[@]} -gt 0 ]]; then
52+
echo "::error::Detected deleted files, which are not supported:"
53+
printf '::error:: %s\n' "${deleted[@]}"
54+
fail=1
55+
fi
56+
if [[ ${#other[@]} -gt 0 ]]; then
57+
echo "::error::Detected renamed/copied/unsupported changes, which are not supported:"
58+
printf '::error:: %s\n' "${other[@]}"
59+
fail=1
60+
fi
61+
if [[ $fail -ne 0 ]]; then
62+
exit 1
63+
fi
64+
65+
if [[ ${#modified[@]} -eq 0 ]]; then
66+
echo "::error::No modified files detected. Nothing to upload."
67+
exit 1
68+
fi
69+
70+
echo "Detected ${#modified[@]} modified file(s):"
71+
printf ' %s\n' "${modified[@]}"
72+
73+
# Write the list for tar consumption (one path per line).
74+
printf '%s\n' "${modified[@]}" > "$MODIFIED_FILES_LIST"
75+
76+
# Emit step outputs.
77+
{
78+
echo "count=${#modified[@]}"
79+
echo "modified_files<<EOF"
80+
printf '%s\n' "${modified[@]}"
81+
echo "EOF"
82+
} >> "$GITHUB_OUTPUT"

0 commit comments

Comments
 (0)