Skip to content

Commit 3efe239

Browse files
CopilotaarongustafsonCopilot
authored
Add manual workflow to mark all syndication cache entries as sent (#176)
* Add mark-all-sent script and workflow for manual cache reset Agent-Logs-Url: https://github.com/aarongustafson/aaron-gustafson.com/sessions/5710e04c-c309-4c8c-bb46-27d76d18095a Co-authored-by: aarongustafson <75736+aarongustafson@users.noreply.github.com> * Fix defensive init and double-count in mark-all-sent.js Agent-Logs-Url: https://github.com/aarongustafson/aaron-gustafson.com/sessions/96f702b2-12c4-4105-93d3-9daa03e760f4 Co-authored-by: aarongustafson <75736+aarongustafson@users.noreply.github.com> * Harden readCache error handling and fail-fast on feed fetch errors Agent-Logs-Url: https://github.com/aarongustafson/aaron-gustafson.com/sessions/4162ce74-28dd-4a2c-bf48-6223f1624174 Co-authored-by: aarongustafson <75736+aarongustafson@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: aarongustafson <75736+aarongustafson@users.noreply.github.com> Co-authored-by: Aaron Gustafson <aaron@easy-designs.net> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 2e7f4b1 commit 3efe239

2 files changed

Lines changed: 327 additions & 0 deletions

File tree

.github/scripts/mark-all-sent.js

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import fs from "fs/promises";
2+
import fetch from "node-fetch";
3+
import path from "path";
4+
5+
const CACHE_DIR = ".github/cache";
6+
const CACHE_FILE = path.join(CACHE_DIR, "syndication-status.json");
7+
8+
const PLATFORM_MAP = {
9+
posts: ["linkedin", "mastodon", "twitter", "bluesky"],
10+
links: ["linkedin", "pinterest", "mastodon", "twitter", "bluesky"],
11+
};
12+
13+
function getArgValue(name) {
14+
const prefix = `${name}=`;
15+
const arg = process.argv.find((entry) => entry.startsWith(prefix));
16+
return arg ? arg.slice(prefix.length) : undefined;
17+
}
18+
19+
function getConfig() {
20+
const contentType =
21+
process.env.MARK_ALL_SENT_CONTENT_TYPE ||
22+
getArgValue("--content-type") ||
23+
"both";
24+
25+
if (!["posts", "links", "both"].includes(contentType)) {
26+
throw new Error(`Invalid content type: ${contentType}`);
27+
}
28+
29+
const dryRun =
30+
process.env.MARK_ALL_SENT_DRY_RUN === "true" ||
31+
process.argv.includes("--dry-run");
32+
33+
const alsoFetchFeeds =
34+
process.env.MARK_ALL_SENT_FETCH_FEEDS === "true" ||
35+
process.argv.includes("--fetch-feeds");
36+
37+
return { contentType, dryRun, alsoFetchFeeds };
38+
}
39+
40+
async function ensureCacheDir() {
41+
await fs.mkdir(CACHE_DIR, { recursive: true });
42+
}
43+
44+
async function readCache() {
45+
await ensureCacheDir();
46+
47+
let raw;
48+
try {
49+
raw = await fs.readFile(CACHE_FILE, "utf8");
50+
} catch (err) {
51+
if (err.code === "ENOENT") {
52+
// Cache file does not exist yet — start fresh.
53+
return {
54+
posts: {},
55+
links: {},
56+
initialized: new Date().toISOString(),
57+
};
58+
}
59+
throw err;
60+
}
61+
62+
// File exists — parse it; a parse failure means the file is corrupt and
63+
// we must not silently overwrite it.
64+
try {
65+
return JSON.parse(raw);
66+
} catch (err) {
67+
throw new Error(
68+
`Cache file exists but could not be parsed (${CACHE_FILE}): ${err.message}`,
69+
);
70+
}
71+
}
72+
73+
async function writeCache(cache) {
74+
await ensureCacheDir();
75+
await fs.writeFile(CACHE_FILE, JSON.stringify(cache, null, 2));
76+
}
77+
78+
async function fetchFeed(feedUrl, label) {
79+
if (!feedUrl) {
80+
throw new Error(`Missing feed URL for ${label}`);
81+
}
82+
83+
const cacheBustedUrl = `${feedUrl}?t=${Date.now()}`;
84+
console.log(`📡 Fetching ${label} feed: ${cacheBustedUrl}`);
85+
86+
const response = await fetch(cacheBustedUrl, {
87+
headers: {
88+
"Cache-Control": "no-cache",
89+
Pragma: "no-cache",
90+
Accept: "application/json",
91+
},
92+
});
93+
94+
if (!response.ok) {
95+
throw new Error(`Failed to fetch ${label} feed: HTTP ${response.status}`);
96+
}
97+
98+
const feed = await response.json();
99+
if (!feed?.items || !Array.isArray(feed.items)) {
100+
throw new Error(`Invalid ${label} feed format`);
101+
}
102+
103+
return feed.items;
104+
}
105+
106+
function markAllPlatformsSuccessful(cache, type, itemId, timestamp) {
107+
// Defensively initialize the type bucket and the item entry in case the
108+
// cache file is partial or corrupt.
109+
if (!cache[type] || typeof cache[type] !== "object") {
110+
cache[type] = {};
111+
}
112+
113+
if (!cache[type][itemId] || typeof cache[type][itemId] !== "object") {
114+
cache[type][itemId] = {
115+
platforms: {},
116+
firstAttempt: timestamp,
117+
};
118+
}
119+
120+
if (
121+
!cache[type][itemId].platforms ||
122+
typeof cache[type][itemId].platforms !== "object"
123+
) {
124+
cache[type][itemId].platforms = {};
125+
}
126+
127+
for (const platform of PLATFORM_MAP[type]) {
128+
cache[type][itemId].platforms[platform] = {
129+
success: true,
130+
timestamp,
131+
markedManually: true,
132+
};
133+
}
134+
135+
cache[type][itemId].lastUpdated = timestamp;
136+
}
137+
138+
function markAllCacheEntriesForType(cache, type, markedIds, timestamp) {
139+
if (!cache[type] || typeof cache[type] !== "object" || Array.isArray(cache[type])) {
140+
cache[type] = {};
141+
}
142+
143+
for (const itemId of Object.keys(cache[type])) {
144+
markAllPlatformsSuccessful(cache, type, itemId, timestamp);
145+
markedIds.add(itemId);
146+
}
147+
}
148+
149+
function markFeedItemsForType(cache, type, items, markedIds, timestamp) {
150+
for (const item of items) {
151+
if (!item.id) continue;
152+
markAllPlatformsSuccessful(cache, type, item.id, timestamp);
153+
markedIds.add(item.id);
154+
}
155+
}
156+
157+
async function main() {
158+
const { contentType, dryRun, alsoFetchFeeds } = getConfig();
159+
const timestamp = new Date().toISOString();
160+
const cache = await readCache();
161+
let totalMarked = 0;
162+
163+
console.log(`🏷️ Marking all syndication cache entries as sent`);
164+
console.log(`🎯 Content type: ${contentType}`);
165+
console.log(
166+
alsoFetchFeeds
167+
? "📡 Will also fetch live feeds to include items not yet in cache"
168+
: "📂 Operating on existing cache entries only",
169+
);
170+
console.log(
171+
dryRun
172+
? "🧪 Dry run only — cache will not be written"
173+
: "✍️ Cache writes enabled",
174+
);
175+
176+
const types = contentType === "both" ? ["posts", "links"] : [contentType];
177+
178+
for (const type of types) {
179+
// Use a Set so each unique item ID is counted exactly once even if it
180+
// appears in both the existing cache and the live feed.
181+
const markedIds = new Set();
182+
183+
// Mark existing cache entries
184+
markAllCacheEntriesForType(cache, type, markedIds, timestamp);
185+
186+
// Optionally fetch live feeds and mark those items too
187+
if (alsoFetchFeeds) {
188+
const feedEnvKey = type === "posts" ? "POSTS_FEED_URL" : "LINKS_FEED_URL";
189+
const feedUrl = process.env[feedEnvKey];
190+
// Feed fetch failure is treated as a hard error when feeds were
191+
// explicitly requested — re-throw so the workflow exits non-zero.
192+
const items = await fetchFeed(feedUrl, type);
193+
markFeedItemsForType(cache, type, items, markedIds, timestamp);
194+
}
195+
196+
console.log(` ✅ ${type}: ${markedIds.size} unique items marked as sent`);
197+
totalMarked += markedIds.size;
198+
}
199+
200+
if (totalMarked === 0) {
201+
console.log("✅ No items found to mark");
202+
return;
203+
}
204+
205+
if (dryRun) {
206+
console.log(`🧪 Dry run complete — ${totalMarked} entries would be updated`);
207+
return;
208+
}
209+
210+
await writeCache(cache);
211+
console.log(`✅ Cache updated: ${CACHE_FILE} (${totalMarked} entries marked as sent)`);
212+
}
213+
214+
main().catch((error) => {
215+
console.error("❌ Failed to mark cache entries as sent:", error.message);
216+
process.exit(1);
217+
});
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
name: Mark All Syndication Cache Entries as Sent
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
content_type:
7+
description: "Content type to mark as sent"
8+
required: false
9+
default: "both"
10+
type: choice
11+
options:
12+
- posts
13+
- links
14+
- both
15+
also_fetch_feeds:
16+
description: "Also fetch live feeds to include items not yet in the cache"
17+
required: false
18+
default: false
19+
type: boolean
20+
dry_run:
21+
description: "Dry run — inspect what would change without writing the cache"
22+
required: false
23+
default: false
24+
type: boolean
25+
26+
# Reuse the same concurrency group as the syndication workflow so this action
27+
# cannot run at the same time as a normal syndication run and vice versa.
28+
concurrency:
29+
group: syndication-${{ github.ref_name }}
30+
cancel-in-progress: false
31+
32+
jobs:
33+
mark-all-sent:
34+
runs-on: ubuntu-latest
35+
36+
steps:
37+
- name: Checkout repository
38+
uses: actions/checkout@v6
39+
40+
- name: Setup Node.js
41+
uses: actions/setup-node@v6
42+
with:
43+
node-version: "20"
44+
45+
- name: Install dependencies
46+
run: npm install node-fetch@3
47+
48+
- name: Resolve latest processed items cache key
49+
id: syndication-cache-latest
50+
env:
51+
GH_TOKEN: ${{ github.token }}
52+
run: |
53+
key_prefix="syndication-cache-${{ github.ref_name }}-"
54+
latest_key=$(curl -fsSL -G \
55+
-H "Authorization: Bearer $GH_TOKEN" \
56+
-H "Accept: application/vnd.github+json" \
57+
--data-urlencode "per_page=100" \
58+
--data-urlencode "key=${key_prefix}" \
59+
--data-urlencode "ref=${{ github.ref }}" \
60+
"${{ github.api_url }}/repos/${{ github.repository }}/actions/caches" | jq -r '.actions_caches | sort_by(.created_at) | last | .key // empty')
61+
62+
if [ -n "$latest_key" ]; then
63+
echo "latest_key=$latest_key" >> "$GITHUB_OUTPUT"
64+
echo "Resolved latest cache key: $latest_key"
65+
else
66+
echo "latest_key=${key_prefix}" >> "$GITHUB_OUTPUT"
67+
echo "No existing cache key found for prefix: $key_prefix"
68+
fi
69+
70+
- name: Restore processed items cache
71+
id: syndication-cache
72+
uses: actions/cache/restore@v5
73+
with:
74+
path: .github/cache
75+
key: ${{ steps.syndication-cache-latest.outputs.latest_key }}
76+
restore-keys: |
77+
syndication-cache-${{ github.ref_name }}-
78+
79+
- name: Inspect cache before update
80+
run: node .github/scripts/inspect-syndication-cache.js
81+
82+
- name: Mark all entries as sent
83+
run: node .github/scripts/mark-all-sent.js
84+
env:
85+
MARK_ALL_SENT_CONTENT_TYPE: ${{ github.event.inputs.content_type }}
86+
MARK_ALL_SENT_DRY_RUN: ${{ github.event.inputs.dry_run }}
87+
MARK_ALL_SENT_FETCH_FEEDS: ${{ github.event.inputs.also_fetch_feeds }}
88+
POSTS_FEED_URL: https://www.aaron-gustafson.com/feeds/latest-posts.json
89+
LINKS_FEED_URL: https://www.aaron-gustafson.com/feeds/latest-links.json
90+
91+
- name: Inspect cache after update
92+
run: node .github/scripts/inspect-syndication-cache.js
93+
94+
- name: Compute processed items cache key
95+
id: syndication-cache-key
96+
if: github.event.inputs.dry_run != 'true'
97+
run: |
98+
if [ -f .github/cache/syndication-status.json ]; then
99+
cache_hash=$(sha256sum .github/cache/syndication-status.json | cut -d' ' -f1)
100+
else
101+
cache_hash=empty
102+
fi
103+
echo "key=syndication-cache-${{ github.ref_name }}-${cache_hash}" >> "$GITHUB_OUTPUT"
104+
105+
- name: Save processed items cache
106+
uses: actions/cache/save@v5
107+
if: github.event.inputs.dry_run != 'true' && steps.syndication-cache.outputs.cache-matched-key != steps.syndication-cache-key.outputs.key
108+
with:
109+
path: .github/cache
110+
key: ${{ steps.syndication-cache-key.outputs.key }}

0 commit comments

Comments
 (0)