Repository navigation
342 lines (305 loc) · 13.5 KB
/
Copy pathdocuments.yml
File metadata and controls
342 lines (305 loc) · 13.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
# SPDX-FileCopyrightText: 2026 Florian Wilhelm <fwilhelm.wgt+github@gmail.com>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
name: Documents
# A worked example of building documents in CI with nothing installed but Docker:
# `ghcr.io/fwilhe2/grind:latest` creates, edits, converts and checks both document
# types, and the results leave as workflow artifacts. Copy this file into another
# repository and it runs there unchanged — the only thing it needs from this one is
# the published image.
#
# Two facts about that image shape everything below, and both bite the first time:
#
# 1. **It is distroless — there is no shell in it.** So `jobs.<id>.container:`
# cannot be used: Actions runs every `run:` step with `sh` *inside* the
# container and there is none to run. Each invocation is its own `docker run`
# instead, which is what the wrapper in the first step of each job is for.
# 2. **The binary is `/grind`, at the image root, and the container has no
# filesystem worth writing to.** Documents arrive on a mounted directory and
# the command names the binary by its absolute path, because `-w` moves the
# working directory away from the one the image's own `CMD` assumes.
#
# It doubles as a check that the image still does what README's "Containers"
# section says it does — which is why it runs weekly as well as on demand.
on:
workflow_dispatch:
push:
branches: [ "main" ]
paths: [ ".github/workflows/documents.yml" ]
schedule:
# Monday morning: the image is rebuilt on every push to main, so this is the
# check that a rebuild has not quietly broken the shape of these commands.
- cron: "17 5 * * 1"
permissions:
contents: read
env:
# The published image is public, so no registry login is needed to pull it.
# `latest` is a multi-arch manifest and follows `main`; a pipeline whose output
# has to be reproducible should pin the digest instead —
# ghcr.io/fwilhe2/grind@sha256:… — exactly as this workflow pins its actions.
GRIND_IMAGE: ghcr.io/fwilhe2/grind:latest
jobs:
spreadsheet:
name: Create, edit and convert a spreadsheet
runs-on: ubuntu-latest
steps:
# Repeated in each job rather than factored out, so that one job is one
# copyable example. Three things in six lines that are all load-bearing:
#
# -u the container writes as root otherwise, and a root-owned document in
# the workspace is one a later step cannot overwrite and the artifact
# upload cannot read.
# -i so that `grind sheet set book.fods A1 -` can read a value from a pipe.
# -v the workspace, since the container's own filesystem is read-only in
# every way that matters. A path that escapes `$PWD` is not visible in
# there, so steps using this wrapper stay in the workspace.
#
# On an SELinux host — a self-hosted Fedora or RHEL runner — the mount needs
# `:z` as well ("$PWD:/work:z"); the GitHub-hosted Ubuntu runners do not.
- name: Put `grind` on PATH
run: |
mkdir -p "$RUNNER_TEMP/bin"
cat > "$RUNNER_TEMP/bin/grind" <<'WRAPPER'
#!/bin/sh
exec docker run --rm -i \
-u "$(id -u):$(id -g)" \
-v "$PWD:/work" -w /work \
"$GRIND_IMAGE" /grind "$@"
WRAPPER
chmod +x "$RUNNER_TEMP/bin/grind"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Pull the image
run: docker pull "$GRIND_IMAGE"
- name: Which build is this
run: grind --version
# `.fods` rather than `.ods`: the flat form is one XML file that `git diff`
# can read, and this build writes changes into it by splicing rather than
# regenerating, so one edited cell is one edited line. A document produced by
# CI and committed back wants that; a document handed to a person may as well
# be the zip, which the conversion step below writes.
- name: Create a document
run: |
mkdir -p out && cd out
grind sheet new sales.fods --force
grind sheet rename sales.fods Sheet1 Sales
# Editing is one verb per invocation, each loading the file and writing it
# back. Undo history does not survive an invocation unless --session names a
# file to keep it in, which a pipeline that only moves forward does not need.
- name: Edit it
run: |
cd out
grind sheet set sales.fods A1 'Region'
grind sheet set sales.fods B1 'Units'
grind sheet set sales.fods C1 'Unit price'
grind sheet set sales.fods D1 'Revenue'
# Data usually arrives as a file rather than as arguments. The delimiter
# is sniffed when nobody names one, and --locale reads 1.234,50 as a
# number where that is how numbers are written.
cat > sales.csv <<'CSV'
North,120,19.99
South,86,19.99
East,143,17.50
West,97,21.00
CSV
grind sheet import-csv sales.fods sales.csv --at A2
# Formulas are ODF's own syntax, stored verbatim — [.B2] is this sheet's
# B2. `fill` shifts the relative references the way a drag handle does,
# so the calculation is written once.
grind sheet set sales.fods D2 '=[.B2]*[.C2]'
grind sheet fill sales.fods D2 D3:D5
grind sheet set sales.fods A6 'Total'
grind sheet set sales.fods D6 '=SUM([.D2:.D5])'
grind sheet name sales.fods revenue 'Sales.D2:D5'
# Formatting is display only and never touches a value: the number in
# D6 is the same number whether or not it is shown with a currency sign.
grind sheet style sales.fods A1:D1 --bold --background silver
grind sheet style sales.fods A6:D6 --bold
grind sheet format sales.fods C2:D6 currency --symbol '$' --grouping
grind sheet recalc sales.fods
# Non-zero exit on an error, so this step is the gate: a formula that reads an
# empty cell, a cached value that is no longer what the formula computes, a
# name pointing nowhere. --hints adds the house-style rules, which are off by
# default because they are opinions rather than defects.
- name: Check it
run: |
cd out
grind lint sales.fods
# The form comes from the output extension and the kind never changes: .ods
# is the zip, .fods the flat XML, .grind the projection — the same document
# as plain text a person can write by hand and a reviewer can read in a diff.
- name: Convert it
run: |
cd out
grind convert sales.fods sales.ods
grind convert sales.fods sales.grind
grind sheet export-csv sales.fods --out sales-export.csv
# What the run produced, in the run's own summary — `view` prints what each
# cell displays, and `info --format json` is the machine-readable shape of
# the same answer, for a step that has to decide something.
- name: Report
run: |
cd out
{
echo '### Sales'
echo '```'
grind sheet view sales.fods A1:D6
echo '```'
echo "Revenue total: $(grind sheet get sales.fods D6)"
echo "Sheets: $(grind --format json info sales.ods | jq -r '.sheets[].name' | paste -sd, -)"
} >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: spreadsheet
path: |
out/sales.fods
out/sales.ods
out/sales.grind
out/sales-export.csv
text:
name: Create, edit and convert a text document
runs-on: ubuntu-latest
steps:
- name: Put `grind` on PATH
run: |
mkdir -p "$RUNNER_TEMP/bin"
cat > "$RUNNER_TEMP/bin/grind" <<'WRAPPER'
#!/bin/sh
exec docker run --rm -i \
-u "$(id -u):$(id -g)" \
-v "$PWD:/work" -w /work \
"$GRIND_IMAGE" /grind "$@"
WRAPPER
chmod +x "$RUNNER_TEMP/bin/grind"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Pull the image
run: docker pull "$GRIND_IMAGE"
# One binary, two applications: `grind text` is the word processor, and the
# suite-level verbs (info, convert, lint) read which of the two a file is out
# of the file's own bytes rather than out of its name.
- name: Create and edit a document
run: |
mkdir -p out && cd out
grind text new notes.fodt --force
grind text insert notes.fodt --heading 1 --text 'Quarterly sales'
grind text insert notes.fodt --text 'Revenue for the quarter came to 8,657.44 across four regions.'
grind text insert notes.fodt --heading 2 --text 'By region'
grind text insert notes.fodt --list 1 --text 'East led on units.'
grind text insert notes.fodt --list 1 --text 'West led on price.'
# Blocks are addressed by position (p2), and a range of characters within
# one is that address plus an offset — so p2+0:p2+7 is the first word of
# the second block, and this bolds it.
grind text format notes.fodt p2+0:p2+7 --bold
- name: Check it
run: |
cd out
grind lint notes.fodt
- name: Convert it
run: |
cd out
grind convert notes.fodt notes.odt
- name: Report
run: |
cd out
{
echo '### Notes'
echo '```'
grind text outline notes.fodt
echo '```'
grind text words notes.fodt | head -1
} >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: text-document
path: |
out/notes.fodt
out/notes.odt
generated:
name: Generate a document from data
runs-on: ubuntu-latest
steps:
- name: Put `grind` on PATH
run: |
mkdir -p "$RUNNER_TEMP/bin"
cat > "$RUNNER_TEMP/bin/grind" <<'WRAPPER'
#!/bin/sh
exec docker run --rm -i \
-u "$(id -u):$(id -g)" \
-v "$PWD:/work" -w /work \
"$GRIND_IMAGE" /grind "$@"
WRAPPER
chmod +x "$RUNNER_TEMP/bin/grind"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: Pull the image
run: docker pull "$GRIND_IMAGE"
# The other way to make a document: a script that *returns* one. This is the
# shape a real pipeline usually wants — the numbers come out of a job that ran
# earlier and the layout is said once, rather than a hundred `sheet set` calls.
#
# A script has no filesystem, network, clock or randomness, and JSON beside it
# is the one thing it may read: data, never code. It cannot open a document
# either, so `build` is the only verb in the suite that runs one.
- name: Write the data and the script
run: |
mkdir -p out && cd out
cat > regions.json <<'JSON'
{
"currency": "$",
"rows": [
{ "region": "North", "units": 120, "price": 19.99 },
{ "region": "South", "units": 86, "price": 19.99 },
{ "region": "East", "units": 143, "price": 17.50 },
{ "region": "West", "units": 97, "price": 21.00 }
]
}
JSON
cat > quarter.rhai <<'RHAI'
let source = json("regions.json");
let money = format("currency").symbol(source.currency).grouping().decimals(2);
let s = sheet("Sales");
s.push(row(["Region", "Units", "Unit price", "Revenue"]).bold());
// `push` returns the row it landed on, so the formula is written for that
// row rather than counted out by hand.
for r in source.rows {
let at = s.push([r.region, r.units, r.price]);
s.set(s.at(at, 3), formula(s.at(at, 1) + "*" + s.at(at, 2)));
}
// `sum_above` is told nothing: it sums the run of numbers directly above
// wherever it lands, which is what an AutoSum does.
let total = s.push(row(["Total", (), (), sum_above()]).bold());
s.format("C2:D" + (total + 1), money);
s.name("revenue", "D2:D" + total);
s.style("A1:D1", style().background("silver"));
// A script's value is its last expression, and it has to be a document.
s
RHAI
- name: Build the document
run: |
cd out
grind build quarter.rhai -o quarter.fods
grind lint quarter.fods
# The same source produces the same bytes — no clock, no randomness, nothing
# read that was not named. Worth asserting in the pipeline that relies on it:
# a document that is byte-identical when nothing changed is one whose commits
# mean something.
- name: The same script twice is the same document
run: |
cd out
grind build quarter.rhai -o again.fods
cmp quarter.fods again.fods
rm again.fods
- name: Report
run: |
cd out
{
echo '### Generated from regions.json'
echo '```'
grind sheet view quarter.fods
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: generated-spreadsheet
path: |
out/quarter.fods
out/regions.json
out/quarter.rhai